//! AST-to-VDBE bytecode compilation (§10.6).
//!
//! Translates parsed SQL statements into VDBE register-based instructions
//! using `ProgramBuilder`. Handles SELECT, INSERT,
//! UPDATE, and DELETE with correct opcode patterns matching C SQLite behavior.
use std::cell::{Cell, RefCell};
use std::env;
use std::sync::Arc;
use crate::{Label, ProgramBuilder, SchemaEvaluationContext};
use fsqlite_ast::{
AssignmentTarget, BinaryOp, ColumnRef, ConflictAction, CreateIndexStatement, DeleteStatement,
Distinctness, Expr, FromClause, FunctionArgs, InSet, IndexedColumn, InsertSource,
InsertStatement, JsonArrow, LimitClause, Literal, NullsOrder, OrderingTerm, QualifiedName,
QualifiedTableRef, ResultColumn, SelectCore, SelectStatement, SortDirection, Span,
TableOrSubquery, TimeTravelClause, TimeTravelTarget, UpdateStatement, UpsertAction,
UpsertClause, UpsertTarget,
};
use fsqlite_error::ErrorCode;
use fsqlite_func::{FunctionArity, FunctionRegistry};
use fsqlite_parser::expr::parse_expr as parse_sql_expr;
use fsqlite_types::opcode::{
IndexCursorMeta, Opcode, P4, SORTER_COMPARE_TOP_N_PREFLIGHT, SORTER_OPEN_TOP_N_REGISTER,
};
use fsqlite_types::record::{PrecomputedRecordHeader, PrecomputedSerialTypeKind};
use fsqlite_types::value::classify_sql_like_fast_path;
use fsqlite_types::{
SmallText, SqliteValue, StrictColumnType, TextEncoding, TypeAffinity,
without_rowid_pk_is_leading, without_rowid_storage_order,
};
// ---------------------------------------------------------------------------
// Thread-local custom aggregate keys for UDF support (bd-2wt.3)
// ---------------------------------------------------------------------------
// Custom aggregate UDFs registered via Connection::register_aggregate_function
// need to be recognized by the codegen so they emit AggStep/AggFinal opcodes
// instead of PureFunc. Arity is part of the key: a custom max/1 must not turn
// scalar max(x,y) into an aggregate, while a custom max/2 must replace that
// scalar-shaped built-in call. A thread-local avoids threading the keys through
// dozens of internal codegen helpers. Connection is !Send/!Sync so all codegen
// runs on a single thread.
thread_local! {
static CUSTOM_AGG_KEYS: RefCell<Vec<(String, FunctionArity)>> = const { RefCell::new(Vec::new()) };
static FUNCTION_REGISTRY: RefCell<Option<Arc<FunctionRegistry>>> = const { RefCell::new(None) };
static USE_BUILTIN_LIKE_GLOB_SEMANTICS: Cell<bool> = const { Cell::new(true) };
static USE_BUILTIN_SCALAR_FUNCTION_SEMANTICS: Cell<bool> = const { Cell::new(true) };
}
struct ConnectionFunctionContextGuard {
custom_aggregate_keys: Vec<(String, FunctionArity)>,
function_registry: Option<Arc<FunctionRegistry>>,
builtin_like_glob_semantics: bool,
builtin_scalar_function_semantics: bool,
}
impl Drop for ConnectionFunctionContextGuard {
fn drop(&mut self) {
let previous_custom_aggregate_keys = std::mem::take(&mut self.custom_aggregate_keys);
CUSTOM_AGG_KEYS.with(|keys| {
*keys.borrow_mut() = previous_custom_aggregate_keys;
});
let previous_function_registry = self.function_registry.take();
FUNCTION_REGISTRY.with(|registry| {
*registry.borrow_mut() = previous_function_registry;
});
USE_BUILTIN_LIKE_GLOB_SEMANTICS.with(|enabled| {
enabled.set(self.builtin_like_glob_semantics);
});
USE_BUILTIN_SCALAR_FUNCTION_SEMANTICS.with(|enabled| {
enabled.set(self.builtin_scalar_function_semantics);
});
}
}
/// Run one codegen invocation with the connection's function-overriding state.
///
/// Custom aggregate names must be lowercase. Each frozen arity contract controls
/// both exact/variadic precedence and the accepted variadic range.
/// `use_builtin_like_glob_semantics` must be false when the connection has
/// replaced an operator-compatible `like()` or `glob()` function: direct LIKE
/// opcodes and LIKE/GLOB prefix-to-range rewrites would otherwise bypass the
/// replacement. `use_builtin_scalar_function_semantics` must be false after
/// any scalar registration because function-bearing partial-index predicates
/// can no longer be assumed to match the semantics under which the index was
/// populated. The previous thread-local state is restored even if `codegen`
/// unwinds.
pub fn with_connection_function_context<R>(
custom_aggregate_keys: Vec<(String, FunctionArity)>,
use_builtin_like_glob_semantics: bool,
use_builtin_scalar_function_semantics: bool,
codegen: impl FnOnce() -> R,
) -> R {
with_connection_function_registry_context(
None,
custom_aggregate_keys,
use_builtin_like_glob_semantics,
use_builtin_scalar_function_semantics,
codegen,
)
}
/// Registry-aware form of [`with_connection_function_context`] used by the
/// connection compiler.
///
/// Keeping the exact immutable registry snapshot in the
/// guard lets constant-expression decisions use frozen scalar safety metadata
/// without invoking user callbacks or racing a later registration.
pub fn with_connection_function_registry_context<R>(
function_registry: Option<Arc<FunctionRegistry>>,
custom_aggregate_keys: Vec<(String, FunctionArity)>,
use_builtin_like_glob_semantics: bool,
use_builtin_scalar_function_semantics: bool,
codegen: impl FnOnce() -> R,
) -> R {
let previous_custom_aggregate_keys = CUSTOM_AGG_KEYS
.with(|keys| std::mem::replace(&mut *keys.borrow_mut(), custom_aggregate_keys));
let previous_function_registry = FUNCTION_REGISTRY
.with(|registry| std::mem::replace(&mut *registry.borrow_mut(), function_registry));
let previous_builtin_like_glob_semantics = USE_BUILTIN_LIKE_GLOB_SEMANTICS
.with(|enabled| enabled.replace(use_builtin_like_glob_semantics));
let previous_builtin_scalar_function_semantics = USE_BUILTIN_SCALAR_FUNCTION_SEMANTICS
.with(|enabled| enabled.replace(use_builtin_scalar_function_semantics));
let _guard = ConnectionFunctionContextGuard {
custom_aggregate_keys: previous_custom_aggregate_keys,
function_registry: previous_function_registry,
builtin_like_glob_semantics: previous_builtin_like_glob_semantics,
builtin_scalar_function_semantics: previous_builtin_scalar_function_semantics,
};
codegen()
}
fn scalar_is_query_constant_for_codegen(name: &str, num_args: i32) -> bool {
FUNCTION_REGISTRY.with(|registry| {
registry
.borrow()
.as_ref()
.and_then(|registry| registry.resolve_scalar(name, num_args))
.is_some_and(|resolved| resolved.query_constancy().is_query_constant())
})
}
/// Builtin scalar functions that consume (and therefore require) their
/// arguments' collation even when no custom registry entry covers them:
/// `NULLIF/2` and the `MIN`/`MAX` scalar forms (2+ arguments).
fn builtin_scalar_consumes_argument_collation(name: &str, num_args: i32) -> bool {
matches!(
(name.to_ascii_uppercase().as_str(), num_args),
("NULLIF", 2) | ("MIN" | "MAX", 2..)
)
}
fn scalar_consumes_argument_collation_for_codegen(name: &str, num_args: i32) -> bool {
FUNCTION_REGISTRY.with(|registry| {
registry.borrow().as_ref().map_or_else(
|| builtin_scalar_consumes_argument_collation(name, num_args),
|registry| {
// bd-rwaxp: when the registry does not cover this (name, arity)
// -- e.g. a partial custom registration shadowing `min/2` but
// not `min/3` -- the BUILTIN is still used at that arity, so the
// builtin NEEDCOLL requirement must survive. `unwrap_or(false)`
// silently dropped it; fall back to the builtin table instead
// (matching the no-registry branch above).
registry
.scalar_consumes_argument_collation(name, num_args)
.unwrap_or_else(|| builtin_scalar_consumes_argument_collation(name, num_args))
},
)
})
}
fn use_builtin_like_glob_semantics() -> bool {
USE_BUILTIN_LIKE_GLOB_SEMANTICS.with(Cell::get)
}
fn use_builtin_scalar_function_semantics() -> bool {
USE_BUILTIN_SCALAR_FUNCTION_SEMANTICS.with(Cell::get)
}
fn use_builtin_scalar_implementation_for_codegen(name: &str, num_args: i32) -> bool {
FUNCTION_REGISTRY.with(|registry| {
registry
.borrow()
.as_ref()
.map_or_else(use_builtin_scalar_function_semantics, |registry| {
registry
.resolve_application_function(name, num_args)
.is_none()
})
})
}
// ---------------------------------------------------------------------------
// Conflict resolution flags for Insert opcode p5 field
// ---------------------------------------------------------------------------
// These match SQLite's OE_* constants for on-error conflict handling.
// The low 4 bits of p5 encode the conflict action.
/// No conflict clause (default behavior: abort on constraint violation).
const OE_ABORT: u16 = 2;
/// ROLLBACK on conflict.
const OE_ROLLBACK: u16 = 1;
/// FAIL on conflict (abort statement but don't rollback transaction).
const OE_FAIL: u16 = 3;
/// IGNORE conflicting row (skip insert without error).
const OE_IGNORE: u16 = 4;
/// REPLACE conflicting row (delete old, insert new).
const OE_REPLACE: u16 = 5;
/// FrankenSQLite-specific p5 flag for `Insert`/`Delete` opcodes that are part
/// of an UPDATE rewrite.
///
/// This intentionally lives above the low 4 OE_* bits because this engine
/// encodes conflict handling directly in `p5`, unlike SQLite's native layout.
const OPFLAG_ISUPDATE: u16 = 0x10;
/// Marks a WITHOUT ROWID table-row `IdxDelete` as an implicit REPLACE
/// deletion whose exact OLD row must be reported for inbound FK actions.
const OPFLAG_REPLACE_VICTIM: u16 = 0x20;
/// Counts a successful clustered-table `IdxInsert` as one logical row change.
/// Secondary-index maintenance must never carry this flag.
const OPFLAG_IDX_NCHANGE: u16 = 0x40;
/// Marks a non-mutating UNIQUE constraint halt. P4 carries the column label.
const OPFLAG_HALT_UNIQUE: u16 = 0x01;
/// Convert AST `ConflictAction` to p5 OE_* flag value.
fn conflict_action_to_oe(action: Option<&ConflictAction>) -> u16 {
match action {
Some(ConflictAction::Rollback) => OE_ROLLBACK,
None | Some(ConflictAction::Abort) => OE_ABORT,
Some(ConflictAction::Fail) => OE_FAIL,
Some(ConflictAction::Ignore) => OE_IGNORE,
Some(ConflictAction::Replace) => OE_REPLACE,
}
}
/// Resolve the effective conflict algorithm for a single constraint.
///
/// A statement-level `INSERT OR <algo>` (`stmt_level`) overrides a constraint's
/// declared `ON CONFLICT <algo>` (`constraint_level`); absent both, the default
/// is ABORT.
fn effective_oe(
stmt_level: Option<ConflictAction>,
constraint_level: Option<ConflictAction>,
) -> u16 {
conflict_action_to_oe(stmt_level.or(constraint_level).as_ref())
}
fn json_access_func_name(arrow: JsonArrow) -> &'static str {
match arrow {
JsonArrow::Arrow => "->",
JsonArrow::DoubleArrow => "->>",
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_sqlite_value(b: &mut ProgramBuilder, value: &SqliteValue, reg: i32) {
match value {
SqliteValue::Null => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
SqliteValue::Integer(value) => {
if let Ok(value) = i32::try_from(*value) {
b.emit_op(Opcode::Integer, value, reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Int64, 0, reg, 0, P4::Int64(*value), 0);
}
}
SqliteValue::Float(value) => {
b.emit_op(Opcode::Real, 0, reg, 0, P4::Real(*value), 0);
}
SqliteValue::Text(value) => {
b.emit_op(
Opcode::String8,
0,
reg,
0,
P4::Str(value.as_str().to_owned()),
0,
);
}
SqliteValue::Blob(value) => {
b.emit_op(
Opcode::Blob,
value.len() as i32,
reg,
0,
P4::Blob(value.as_ref().to_vec()),
0,
);
}
}
}
fn bound_outer_affinity_code(affinity: Option<TypeAffinity>) -> u8 {
affinity.map_or(b'A', |affinity| affinity as u8)
}
fn scalar_function_p4(canonical_name: String, args: &[Expr], ctx: Option<&ScanCtx<'_>>) -> P4 {
if scalar_consumes_argument_collation_for_codegen(
&canonical_name,
i32::try_from(args.len()).unwrap_or(i32::MAX),
) && let Some(collation) = scalar_function_argument_collation_ctx(args, ctx)
{
return P4::FuncNameCollated(canonical_name, collation);
}
P4::FuncName(canonical_name)
}
// ---------------------------------------------------------------------------
// Schema metadata (minimal info needed for codegen)
// ---------------------------------------------------------------------------
/// Column metadata needed by the code generator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColumnInfo {
/// Column name.
pub name: String,
/// Type affinity character: 'D'/'d' (integer), 'E'/'e' (real), 'B' (text),
/// 'C' (numeric), 'A' or other (blob/none).
pub affinity: char,
/// True if this column is the INTEGER PRIMARY KEY (rowid alias).
/// Column reads for IPK columns must emit `Rowid` instead of `Column`
/// because the value is stored as the B-tree key, not in the data record.
pub is_ipk: bool,
/// Type name as written in the CREATE TABLE statement (e.g. "TEXT", "INTEGER").
pub type_name: Option<String>,
/// True if the column has a NOT NULL constraint.
pub notnull: bool,
/// True if the column has a UNIQUE constraint.
pub unique: bool,
/// Default value expression as SQL text (e.g. "'open'", "0", "CURRENT_TIMESTAMP").
pub default_value: Option<String>,
/// Strict type for STRICT tables; `None` for non-STRICT tables.
pub strict_type: Option<StrictColumnType>,
/// Generated column expression as SQL text, if this is a generated column.
pub generated_expr: Option<String>,
/// Whether the generated column is STORED (`true`) or VIRTUAL (`false`).
/// `None` for non-generated columns.
pub generated_stored: Option<bool>,
/// Column collation sequence name (e.g. "NOCASE", "BINARY", "RTRIM").
/// `None` means the default (BINARY).
pub collation: Option<String>,
/// Conflict-resolution algorithm declared on this column's NOT NULL or
/// (INTEGER PRIMARY KEY) constraint via `ON CONFLICT <algo>`. `None` means
/// the default (ABORT). UNIQUE-constraint conflict actions live on the
/// corresponding `IndexSchema::conflict_action` instead.
pub conflict_action: Option<ConflictAction>,
}
impl ColumnInfo {
/// Create a basic `ColumnInfo` without type/notnull/default metadata.
#[must_use]
pub fn basic(name: impl Into<String>, affinity: char, is_ipk: bool) -> Self {
Self {
name: name.into(),
affinity,
is_ipk,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
}
}
}
/// Index metadata needed for codegen (index-scan SELECT).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexSchema {
/// Index name.
pub name: String,
/// Root page number.
pub root_page: i32,
/// Indexed column names (leftmost first).
///
/// This is populated only when every key term is a plain column
/// reference. Expression indexes keep their executable term SQL in
/// `key_expressions` and leave `columns` empty so planner fast paths do
/// not accidentally treat them as simple column-lookup indexes.
pub columns: Vec<String>,
/// Executable SQL for each key term in storage order.
pub key_expressions: Vec<String>,
/// Sort direction for each logical key term.
///
/// Empty means "all ASC" for legacy callers/tests that do not yet
/// populate per-term ordering metadata.
pub key_sort_directions: Vec<SortDirection>,
/// Optional partial-index predicate as SQL text.
pub where_clause: Option<String>,
/// Whether this index enforces a UNIQUE constraint.
pub is_unique: bool,
/// Per-key-term collation sequences (e.g. `NOCASE`, `RTRIM`).
///
/// `None` means "use the default (BINARY) collation" for that position.
/// Empty vec means "all BINARY" for legacy callers/tests.
pub key_collations: Vec<Option<String>>,
/// Conflict-resolution algorithm declared on the UNIQUE / PRIMARY KEY
/// constraint backing this index via `ON CONFLICT <algo>`. `None` means the
/// default (ABORT) unless overridden by a statement-level `INSERT OR <algo>`.
pub conflict_action: Option<ConflictAction>,
}
impl IndexSchema {
/// Number of logical key terms before the trailing rowid suffix.
#[must_use]
pub fn key_term_count(&self) -> usize {
if self.key_expressions.is_empty() {
self.columns.len()
} else {
self.key_expressions.len()
}
}
/// Return the SQL fragment for the `key_pos`th key term.
#[must_use]
pub fn key_term_sql(&self, key_pos: usize) -> Option<&str> {
if self.key_expressions.is_empty() {
self.columns.get(key_pos).map(String::as_str)
} else {
self.key_expressions.get(key_pos).map(String::as_str)
}
}
/// Whether the `key_pos`th logical key term sorts descending.
#[must_use]
pub fn key_term_descending(&self, key_pos: usize) -> bool {
matches!(
self.key_sort_directions.get(key_pos),
Some(SortDirection::Desc)
)
}
/// Return the collation sequence for the `key_pos`th key term, if any.
#[must_use]
pub fn key_term_collation(&self, key_pos: usize) -> Option<&str> {
self.key_collations.get(key_pos).and_then(|c| c.as_deref())
}
/// Whether planner / lookup fast paths may safely treat this as a simple
/// non-partial column index.
#[must_use]
pub fn supports_direct_column_lookup(&self) -> bool {
self.where_clause.is_none()
&& !self.columns.is_empty()
&& self.columns.len() == self.key_term_count()
}
/// Whether REPLACE cleanup metadata can reconstruct the key from raw row
/// payload columns alone.
#[must_use]
pub fn supports_replace_cleanup_meta(&self) -> bool {
self.supports_direct_column_lookup()
}
/// Human-readable key label for diagnostics / constraint errors.
#[must_use]
pub fn key_label(&self) -> String {
if self.key_expressions.is_empty() {
self.columns.join(", ")
} else {
self.key_expressions.join(", ")
}
}
/// bd-a506j F1b: table-qualified key label for constraint-error messages —
/// each key column carries the `table.` prefix, matching stock SQLite's
/// "UNIQUE constraint failed: t.a, t.b" (not "t.a, b"). Single-column keys
/// render "t.a" exactly as before.
#[must_use]
pub fn key_label_qualified(&self, table: &str) -> String {
let cols = if self.key_expressions.is_empty() {
&self.columns
} else {
&self.key_expressions
};
cols.iter()
.map(|c| format!("{table}.{c}"))
.collect::<Vec<_>>()
.join(", ")
}
}
/// A validated explicit-index definition before a physical root page exists.
///
/// This is the common binding result for live `CREATE INDEX` and persisted
/// `sqlite_master` reloads. Root allocation deliberately remains a caller
/// responsibility so malformed catalog SQL and invalid live DDL fail before
/// they can mutate storage.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundExplicitIndex {
/// Index name exactly as declared by the bound statement.
pub name: String,
/// Plain indexed column names, or empty for an expression index.
pub columns: Vec<String>,
/// Executable SQL for every key term when any term is an expression.
pub key_expressions: Vec<String>,
/// Effective sort direction for every key term.
pub key_sort_directions: Vec<SortDirection>,
/// Optional validated partial-index predicate SQL.
pub where_clause: Option<String>,
/// Whether the index enforces uniqueness.
pub is_unique: bool,
/// Effective collation for every key term.
pub key_collations: Vec<Option<String>>,
}
impl BoundExplicitIndex {
/// Attach an already-allocated root page to this validated definition.
#[must_use]
pub fn into_index_schema(self, root_page: i32) -> IndexSchema {
IndexSchema {
name: self.name,
root_page,
columns: self.columns,
key_expressions: self.key_expressions,
key_sort_directions: self.key_sort_directions,
where_clause: self.where_clause,
is_unique: self.is_unique,
key_collations: self.key_collations,
conflict_action: None,
}
}
}
/// Planner-selected single-table access-path family that lowering may honor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlannerSelectAccessKind {
/// Lower as a plain table scan.
FullTableScan,
/// Lower as a direct rowid lookup.
RowidLookup,
/// Lower as an equality probe on a named index.
IndexEquality,
/// Lower as a bounded range scan on a named index.
IndexRange,
}
impl PlannerSelectAccessKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::FullTableScan => "full_table_scan",
Self::RowidLookup => "rowid_lookup",
Self::IndexEquality => "index_equality",
Self::IndexRange => "index_range",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlannerIndexRangeBound {
/// Bound expression evaluated once before the scan begins.
pub expr: Expr,
/// Whether the bound is inclusive.
pub inclusive: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PlannerIndexRangeTarget {
/// Optional lower bound (`>=` / `>` / `BETWEEN` low).
pub lower: Option<PlannerIndexRangeBound>,
/// Optional upper bound (`<=` / `<` / `BETWEEN` high).
pub upper: Option<PlannerIndexRangeBound>,
}
/// Planner-produced directive for a single-table SELECT lowering path.
#[derive(Debug, Clone, PartialEq)]
pub struct SelectPlannerDirective {
/// Deterministic planner artifact identity.
pub plan_id: String,
/// Planner artifact generation/version.
pub plan_generation: u64,
/// Human-readable planner surface name.
pub planner_surface: String,
/// Table the directive applies to.
pub table_name: String,
/// Index to use when the access path is index-backed.
pub index_name: Option<String>,
/// Human-readable leading key term that drives the probe. For plain
/// indexes this is the leading column name; for expression indexes it is
/// the indexed expression text.
pub index_key_label: Option<String>,
/// Whether the leading key term is an expression rather than a plain
/// column reference.
pub index_key_is_expression: bool,
/// Optional equality probe payload that lowering can emit directly.
pub index_equality_target: Option<Expr>,
/// Optional range probe payload that lowering can emit directly.
pub index_range_target: Option<PlannerIndexRangeTarget>,
/// Whether the planner expects a covering-index lowering.
pub covering: bool,
/// Access-path family lowering should consume.
pub access_kind: PlannerSelectAccessKind,
}
/// A foreign key constraint definition stored on the child table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FkDef {
/// Column indices in the child table that form the FK.
pub child_columns: Vec<usize>,
/// Owning column for a column-level `REFERENCES` clause; `None` for a
/// table-level `FOREIGN KEY` constraint.
pub owner_column: Option<String>,
/// Referenced (parent) table name.
pub parent_table: String,
/// Referenced column names in the parent table.
/// Empty means the parent's implicit rowid.
pub parent_columns: Vec<String>,
/// Action on parent row deletion.
pub on_delete: FkActionType,
/// Action on parent row update.
pub on_update: FkActionType,
/// `true` for `DEFERRABLE INITIALLY DEFERRED` constraints, whose
/// parent-existence check is deferred to COMMIT rather than checked at the
/// statement (bd-do0d6).
pub deferred: bool,
}
/// A CHECK constraint together with its schema-level ownership.
///
/// SQLite drops a column-level CHECK with its owning column, while a
/// table-level CHECK or a CHECK owned by another column remains a dependency
/// that can prevent `ALTER TABLE ... DROP COLUMN`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckConstraint {
/// Constraint expression as SQL text.
pub expr: String,
/// Owning column for a column-level CHECK; `None` for table-level CHECKs.
pub owner_column: Option<String>,
/// The CHECK constraint's name (`CONSTRAINT <name> CHECK(...)`), if any.
/// SQLite reports a NAMED check violation as `CHECK constraint failed:
/// <name>` and an unnamed one as `CHECK constraint failed: <expr>`.
pub name: Option<String>,
}
/// Foreign key action type (mirrors `fsqlite_ast::ForeignKeyActionType`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum FkActionType {
/// No action (default) — raise error if children exist.
#[default]
NoAction,
/// Propagate delete/update to children.
Cascade,
/// Set child FK columns to NULL.
SetNull,
/// Set child FK columns to their default value.
SetDefault,
/// Like `NoAction` but checked immediately (not deferred).
Restrict,
}
/// Minimal table schema needed by the code generator.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableSchema {
/// Table name.
pub name: String,
/// Root page of the table's B-tree.
pub root_page: i32,
/// Column definitions in storage order.
pub columns: Vec<ColumnInfo>,
/// Available indexes.
pub indexes: Vec<IndexSchema>,
/// Whether this table uses SQLite STRICT typing rules.
pub strict: bool,
/// Whether this table is declared WITHOUT ROWID.
pub without_rowid: bool,
/// PRIMARY KEY constraints expressed as ordered column-name groups.
///
/// INTEGER PRIMARY KEY rowid aliases continue to use `ColumnInfo::is_ipk`;
/// this field preserves non-rowid and composite PRIMARY KEY shape for SQL
/// re-rendering during ALTER TABLE / persistence round-trips.
pub primary_key_constraints: Vec<Vec<String>>,
/// Foreign key constraints declared on this table (child side).
pub foreign_keys: Vec<FkDef>,
/// CHECK constraints with durable column-vs-table ownership.
pub check_constraints: Vec<CheckConstraint>,
}
impl TableSchema {
/// Build an affinity string for `MakeRecord` (one char per column).
/// IPK columns are marked with 'X' so `MakeRecord` writes a NULL placeholder
/// while the real key continues to come from the rowid.
#[must_use]
pub fn affinity_string(&self) -> String {
self.columns
.iter()
.map(|c| if c.is_ipk { 'X' } else { c.affinity })
.collect()
}
/// Find a column's 0-based index by name (case-insensitive).
#[must_use]
pub fn column_index(&self, name: &str) -> Option<usize> {
self.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(name))
}
/// Find an index by a column name (returns first index whose leftmost
/// column matches).
#[must_use]
pub fn index_for_column(&self, col_name: &str) -> Option<&IndexSchema> {
// WITHOUT ROWID secondary indexes are keyed by (cols..., primary key),
// not (cols..., rowid), so the rowid-based IdxRowid/SeekRowid access
// path cannot use them. Force a full table scan instead.
if self.without_rowid {
return None;
}
self.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
})
}
/// Find a single-column direct-lookup index for `col_name` whose
/// comparison collation matches the join predicate.
#[must_use]
pub fn single_column_index_for_column_with_collation(
&self,
col_name: &str,
comparison_collation: Option<&str>,
) -> Option<&IndexSchema> {
if self.without_rowid {
return None;
}
self.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.columns.len() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
&& direct_lookup_index_collation_matches_join(idx, comparison_collation)
})
}
/// Find a direct-lookup UNIQUE index that guarantees at most one row for
/// an equality probe on `col_name` and whose comparison collation matches
/// the join predicate.
#[must_use]
pub fn unique_single_column_index_for_column(
&self,
col_name: &str,
comparison_collation: Option<&str>,
) -> Option<&IndexSchema> {
if self.without_rowid {
return None;
}
self.indexes.iter().find(|idx| {
idx.is_unique
&& idx.columns.len() == 1
&& idx.supports_direct_column_lookup()
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
&& direct_lookup_index_collation_matches_join(idx, comparison_collation)
})
}
/// STRICT type-check pattern for `Opcode::TypeCheck` (`I`,`R`,`T`,`L`,`A`).
#[must_use]
pub fn strict_type_pattern(&self) -> Option<String> {
if !self.strict {
return None;
}
Some(
self.columns
.iter()
.map(|col| strict_type_code(col.strict_type))
.collect(),
)
}
fn resolves_to_hidden_rowid(&self, name: &str) -> bool {
!self.without_rowid && self.column_index(name).is_none() && is_hidden_rowid_alias_name(name)
}
}
/// Validate and bind an explicit `CREATE INDEX` without allocating storage.
///
/// `expected_index_name` and `expected_table_name` let catalog reload callers
/// bind the SQL text to the surrounding `sqlite_master` row. Live DDL callers
/// pass the names from `stmt` itself. Identifier comparisons are
/// case-insensitive, while the returned metadata preserves the spelling from
/// the declaration.
///
/// This pass validates expression shape, every table column reference, and
/// collation placement. It intentionally does not decide whether a collation
/// name is registered: that is connection-local state and must be checked by
/// the caller before allocating the root page.
pub fn bind_explicit_index(
stmt: &CreateIndexStatement,
expected_index_name: &str,
expected_table_name: &str,
table: &TableSchema,
) -> Result<BoundExplicitIndex, CodegenError> {
if stmt.name.name.is_empty() || expected_index_name.is_empty() {
return Err(CodegenError::Unsupported(
"malformed CREATE INDEX identity: empty index name".to_owned(),
));
}
if !stmt.name.name.eq_ignore_ascii_case(expected_index_name) {
return Err(CodegenError::Unsupported(format!(
"CREATE INDEX identity mismatch: statement names `{}`, expected `{expected_index_name}`",
stmt.name.name
)));
}
if stmt.table.is_empty() || expected_table_name.is_empty() {
return Err(CodegenError::Unsupported(
"malformed CREATE INDEX identity: empty table name".to_owned(),
));
}
if !stmt.table.eq_ignore_ascii_case(expected_table_name) {
return Err(CodegenError::Unsupported(format!(
"CREATE INDEX table identity mismatch: statement targets `{}`, expected `{expected_table_name}`",
stmt.table
)));
}
if !table.name.eq_ignore_ascii_case(expected_table_name) {
return Err(CodegenError::TableNotFound(expected_table_name.to_owned()));
}
if stmt.columns.is_empty() {
return Err(CodegenError::Unsupported(format!(
"malformed CREATE INDEX `{}`: at least one key term is required",
stmt.name.name
)));
}
let mut simple_columns = Vec::with_capacity(stmt.columns.len());
let mut key_sort_directions = Vec::with_capacity(stmt.columns.len());
let mut key_collations = Vec::with_capacity(stmt.columns.len());
for (term_index, indexed) in stmt.columns.iter().enumerate() {
let context = format!("key term {}", term_index + 1);
validate_explicit_index_term(indexed, table, &context)?;
let simple_column = explicit_index_simple_column_name(&indexed.expr);
if let Some(column_name) = simple_column {
if table.column_index(column_name).is_none() {
return Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: column_name.to_owned(),
});
}
simple_columns.push(column_name.to_owned());
}
let declared_collation = simple_column
.and_then(|column_name| table.column_index(column_name))
.and_then(|column_index| table.columns[column_index].collation.as_deref());
// The collation of an expression-index key is not the collation of
// every comparison performed inside that expression. SQLite assigns a
// non-BINARY key collation only to a bare column (which inherits its
// declaration) or to a COLLATE operator at the root of the indexed
// expression. Nested COLLATE operators, CAST, and unary plus can still
// be runtime dependencies without changing the stored key ordering.
let root_collation = match &indexed.expr {
Expr::Collate { collation, .. } => Some(collation.as_str()),
_ => None,
};
let effective_collation = indexed
.collation
.as_deref()
.or(root_collation)
.or(declared_collation);
validate_explicit_index_collation(effective_collation, &context)?;
key_collations.push(effective_collation.map(str::to_owned));
key_sort_directions.push(indexed.direction.unwrap_or(SortDirection::Asc));
}
if let Some(predicate) = stmt.where_clause.as_ref() {
validate_explicit_index_expr_shape(predicate, "partial-index predicate")?;
validate_single_table_expr_columns(predicate, table, None)?;
}
let all_terms_are_simple = simple_columns.len() == stmt.columns.len();
let (columns, key_expressions) = if all_terms_are_simple {
(simple_columns, Vec::new())
} else {
(
Vec::new(),
stmt.columns
.iter()
.map(|indexed| indexed.expr.to_string())
.collect(),
)
};
Ok(BoundExplicitIndex {
name: stmt.name.name.clone(),
columns,
key_expressions,
key_sort_directions,
where_clause: stmt.where_clause.as_ref().map(ToString::to_string),
is_unique: stmt.unique,
key_collations,
})
}
fn explicit_index_simple_column_name(expr: &Expr) -> Option<&str> {
match expr {
Expr::Column(col_ref, _) if col_ref.table.is_none() => Some(col_ref.column.as_ref()),
// SQLite's indexed-column grammar resolves this legacy spelling as a
// column identifier rather than a constant string expression.
Expr::Literal(Literal::String(name), _) => Some(name),
Expr::Collate { expr, .. } => explicit_index_simple_column_name(expr),
_ => None,
}
}
fn validate_explicit_index_term(
indexed: &IndexedColumn,
table: &TableSchema,
context: &str,
) -> Result<(), CodegenError> {
validate_explicit_index_collation(indexed.collation.as_deref(), context)?;
validate_explicit_index_expr_shape(&indexed.expr, context)?;
validate_explicit_index_key_columns(&indexed.expr, table, context)
}
fn validate_explicit_index_key_columns(
expr: &Expr,
table: &TableSchema,
context: &str,
) -> Result<(), CodegenError> {
validate_expr_columns_with(expr, &|column| {
if column.table.is_some() {
return Err(malformed_explicit_index_expr(
context,
"the `.` operator is prohibited in index key expressions",
));
}
if table.column_index(&column.column).is_some() {
return Ok(());
}
Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: column.column.to_string(),
})
})
}
fn validate_explicit_index_collation(
collation: Option<&str>,
context: &str,
) -> Result<(), CodegenError> {
if collation.is_some_and(str::is_empty) {
return Err(CodegenError::Unsupported(format!(
"malformed CREATE INDEX {context}: empty collation name"
)));
}
Ok(())
}
fn malformed_explicit_index_expr(context: &str, detail: &str) -> CodegenError {
CodegenError::Unsupported(format!("malformed CREATE INDEX {context}: {detail}"))
}
fn validate_explicit_index_expr_shape(expr: &Expr, context: &str) -> Result<(), CodegenError> {
match expr {
Expr::Literal(..) | Expr::Column(..) => Ok(()),
Expr::BoundOuterValue { .. } => Err(malformed_explicit_index_expr(
context,
"bound outer-query values are internal and are not allowed",
)),
Expr::BinaryOp { left, right, .. }
| Expr::JsonAccess {
expr: left,
path: right,
..
} => {
validate_explicit_index_expr_shape(left, context)?;
validate_explicit_index_expr_shape(right, context)
}
Expr::UnaryOp { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
validate_explicit_index_expr_shape(expr, context)
}
Expr::Collate {
expr, collation, ..
} => {
validate_explicit_index_collation(Some(collation), context)?;
validate_explicit_index_expr_shape(expr, context)
}
Expr::Between {
expr, low, high, ..
} => {
validate_explicit_index_expr_shape(expr, context)?;
validate_explicit_index_expr_shape(low, context)?;
validate_explicit_index_expr_shape(high, context)
}
Expr::In { expr, set, .. } => {
validate_explicit_index_expr_shape(expr, context)?;
let InSet::List(values) = set else {
return Err(malformed_explicit_index_expr(
context,
"subqueries and table-valued IN operands are not allowed",
));
};
for value in values {
validate_explicit_index_expr_shape(value, context)?;
}
Ok(())
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
validate_explicit_index_expr_shape(expr, context)?;
validate_explicit_index_expr_shape(pattern, context)?;
if let Some(escape) = escape {
validate_explicit_index_expr_shape(escape, context)?;
}
Ok(())
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
if whens.is_empty() {
return Err(malformed_explicit_index_expr(
context,
"CASE expression has no WHEN terms",
));
}
if let Some(operand) = operand {
validate_explicit_index_expr_shape(operand, context)?;
}
for (when_expr, then_expr) in whens {
validate_explicit_index_expr_shape(when_expr, context)?;
validate_explicit_index_expr_shape(then_expr, context)?;
}
if let Some(else_expr) = else_expr {
validate_explicit_index_expr_shape(else_expr, context)?;
}
Ok(())
}
Expr::FunctionCall {
name,
args,
distinct,
order_by,
filter,
over,
..
} => {
if name.is_empty()
|| *distinct
|| !order_by.is_empty()
|| filter.is_some()
|| over.is_some()
|| matches!(args, FunctionArgs::Star)
{
return Err(malformed_explicit_index_expr(
context,
"aggregate or window function term is not allowed",
));
}
let FunctionArgs::List(values) = args else {
unreachable!("star arguments were rejected above");
};
for value in values {
validate_explicit_index_expr_shape(value, context)?;
}
Ok(())
}
Expr::Exists { .. } | Expr::Subquery(..) => Err(malformed_explicit_index_expr(
context,
"subqueries are not allowed",
)),
Expr::Raise { .. } => Err(malformed_explicit_index_expr(
context,
"RAISE expressions are not allowed",
)),
Expr::RowValue(..) => Err(malformed_explicit_index_expr(
context,
"row-value key terms are not allowed",
)),
Expr::Placeholder(..) => Err(malformed_explicit_index_expr(
context,
"bind parameters are not allowed",
)),
}
}
fn strict_type_code(strict_type: Option<StrictColumnType>) -> char {
match strict_type.unwrap_or(StrictColumnType::Any) {
StrictColumnType::Integer => 'I',
StrictColumnType::Real => 'R',
StrictColumnType::Text => 'T',
StrictColumnType::Blob => 'L',
StrictColumnType::Any => 'A',
}
}
fn emit_strict_type_check(b: &mut ProgramBuilder, table: &TableSchema, first_reg: i32) {
if let Some(pattern) = table.strict_type_pattern() {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let n_cols = table.columns.len() as i32;
// Encode "pattern\ttable_name\tcol1\tcol2\t..." for error messages.
let mut encoded = pattern;
encoded.push('\t');
encoded.push_str(&table.name);
for col in &table.columns {
encoded.push('\t');
encoded.push_str(&col.name);
}
b.emit_op(Opcode::TypeCheck, first_reg, n_cols, 0, P4::Str(encoded), 0);
}
}
/// Find the UNIQUE index matching the UPSERT target columns, if any.
///
/// Returns `(index_offset, &IndexSchema)` when the target columns match a
/// UNIQUE index on the table. Returns `None` when the target is absent,
/// refers to the PRIMARY KEY, or does not match any UNIQUE index.
pub fn find_upsert_target_index<'a>(
table: &'a TableSchema,
target: Option<&UpsertTarget>,
) -> Option<(usize, &'a IndexSchema)> {
let target = target?;
// Only plain columns can match this direct UNIQUE-index probe. Expression
// targets fail closed until the probe can evaluate expression-index keys.
let target_cols: Vec<(&str, Option<&str>)> = target
.columns
.iter()
.map(|indexed_column| match &indexed_column.expr {
Expr::Column(column, _) => {
Some((column.column.as_ref(), indexed_column.collation.as_deref()))
}
_ => None,
})
.collect::<Option<Vec<_>>>()?;
if target_cols.is_empty() {
return None;
}
// Check if the target matches a UNIQUE index (not the PK).
for (idx_offset, index) in table.indexes.iter().enumerate() {
if !index.is_unique
|| index.columns.is_empty()
|| index.columns.len() != index.key_term_count()
|| index.columns.len() != target_cols.len()
{
continue;
}
let mut matched_index_columns = vec![false; index.columns.len()];
let columns_match = target_cols.iter().all(|(target_column, target_collation)| {
let Some(index_position) =
index
.columns
.iter()
.enumerate()
.position(|(index_position, index_column)| {
matched_index_columns
.get(index_position)
.is_some_and(|matched| !matched)
&& index_column.eq_ignore_ascii_case(target_column)
&& target_collation.is_none_or(|target_collation| {
index
.key_term_collation(index_position)
.unwrap_or("BINARY")
.eq_ignore_ascii_case(target_collation)
})
})
else {
return false;
};
let Some(matched) = matched_index_columns.get_mut(index_position) else {
return false;
};
*matched = true;
true
});
if columns_match && upsert_target_matches_index_predicate(table, target, index) {
return Some((idx_offset, index));
}
}
None
}
/// SQLite permits an arbitrary conflict-target WHERE clause to accompany a
/// non-partial UNIQUE index. A partial index, however, is an arbiter only when
/// the target predicate structurally matches its stored predicate.
fn upsert_target_matches_index_predicate(
table: &TableSchema,
target: &UpsertTarget,
index: &IndexSchema,
) -> bool {
let Some(index_predicate_sql) = index.where_clause.as_deref() else {
return true;
};
let Some(target_predicate) = target.where_clause.as_ref() else {
return false;
};
let Ok(index_predicate) = parse_sql_expr(index_predicate_sql) else {
return false;
};
expressions_match_table_locally(target_predicate, &index_predicate, table, None)
}
pub fn upsert_target_matches_rowid_primary_key(table: &TableSchema, target: &UpsertTarget) -> bool {
let [indexed_column] = target.columns.as_slice() else {
return false;
};
if indexed_column.collation.is_some() {
return false;
}
let Expr::Column(column, _) = &indexed_column.expr else {
return false;
};
table
.column_index(&column.column)
.and_then(|index| table.columns.get(index))
.is_some_and(|column| column.is_ipk)
|| table.resolves_to_hidden_rowid(&column.column)
}
/// Match a WITHOUT ROWID table's PRIMARY KEY as an `ON CONFLICT` arbiter.
///
/// The PK is a valid arbiter but is stored as the clustering key in
/// `primary_key_constraints` — not as an entry in `table.indexes` (so
/// `find_upsert_target_index` misses it) and not as an `is_ipk` rowid alias (so
/// `upsert_target_matches_rowid_primary_key` misses it). Match a plain,
/// non-collated, non-partial target whose column set equals the PRIMARY KEY
/// column set (order-independent, like a UNIQUE-index arbiter).
#[must_use]
pub fn upsert_target_matches_without_rowid_primary_key(
table: &TableSchema,
target: &UpsertTarget,
) -> bool {
if !table.without_rowid || target.where_clause.is_some() {
return false;
}
let mut target_cols: Vec<&str> = Vec::with_capacity(target.columns.len());
for indexed in &target.columns {
// Only plain, non-collated column targets can name the PRIMARY KEY here.
if indexed.collation.is_some() {
return false;
}
let Expr::Column(column, _) = &indexed.expr else {
return false;
};
target_cols.push(column.column.as_ref());
}
let Some(pk_group) = table.primary_key_constraints.first() else {
return false;
};
if target_cols.is_empty() || pk_group.len() != target_cols.len() {
return false;
}
// Order-independent set match against the PRIMARY KEY columns.
let mut matched = vec![false; pk_group.len()];
target_cols.iter().all(|target_column| {
let Some(position) = pk_group.iter().enumerate().position(|(index, pk_column)| {
matched.get(index).is_some_and(|m| !m) && pk_column.eq_ignore_ascii_case(target_column)
}) else {
return false;
};
matched[position] = true;
true
})
}
/// Emit UPSERT DO UPDATE assignments into `target_regs`.
///
/// For each assignment, evaluates the RHS expression using two contexts:
/// - `existing_ctx`: resolves unqualified column refs to existing row values
/// - `excluded_ctx`: resolves `excluded.col` refs to the attempted insert values
///
/// The result is written into the appropriate slot of `target_regs`.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
fn emit_upsert_assignments(
b: &mut ProgramBuilder,
assignments: &[fsqlite_ast::Assignment],
table: &TableSchema,
target_regs: i32,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
existing_hidden_rowid_reg: Option<i32>,
excluded_hidden_rowid_reg: i32,
) -> Result<(), CodegenError> {
for assign in assignments {
match &assign.target {
AssignmentTarget::Column(name) => {
let col_idx =
table
.column_index(name)
.ok_or_else(|| CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
})?;
let dest_reg = target_regs + col_idx as i32;
emit_upsert_expr(
b,
&assign.value,
dest_reg,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
AssignmentTarget::ColumnList(columns) => {
let Expr::RowValue(values, _) = &assign.value else {
return Err(CodegenError::Unsupported(
"multi-column SET requires a row-value expression".to_owned(),
));
};
if columns.len() != values.len() {
return Err(CodegenError::Unsupported(format!(
"multi-column SET arity mismatch: {} targets, {} values",
columns.len(),
values.len()
)));
}
for (col_name, value_expr) in columns.iter().zip(values) {
let col_idx = table.column_index(col_name).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_name.to_owned(),
}
})?;
let dest_reg = target_regs + col_idx as i32;
emit_upsert_expr(
b,
value_expr,
dest_reg,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
}
}
}
Ok(())
}
fn upsert_declared_collation(
expr: &Expr,
existing_ctx: &ScanCtx<'_>,
_excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> Option<String> {
let source = declared_collation_source_expr(expr);
if let Expr::BoundOuterValue { collation, .. } = source {
return collation.as_name().map(str::to_owned);
}
let Expr::Column(col_ref, _) = source else {
return None;
};
// SQLite lowers excluded.* references to TK_REGISTER values. The register
// keeps its runtime value but does not inherit the target column's declared
// collation. An explicit COLLATE wrapper is handled by the caller before
// this declared-collation fallback.
if is_upsert_excluded_pseudo_table(col_ref, table, existing_ctx.table_alias) {
return None;
}
let source_table = existing_ctx.table;
let source_alias = existing_ctx.table_alias;
if col_ref.table.as_deref().is_some_and(|qualifier| {
!qualifier.eq_ignore_ascii_case("excluded")
&& !matches_table_or_alias(qualifier, source_table, source_alias)
}) {
return None;
}
if let Some(index) = source_table.column_index(&col_ref.column) {
return Some(
source_table.columns[index]
.collation
.as_deref()
.unwrap_or("BINARY")
.to_owned(),
);
}
source_table
.resolves_to_hidden_rowid(&col_ref.column)
.then(|| "BINARY".to_owned())
}
fn upsert_effective_collation(
expr: &Expr,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> Option<String> {
extract_collation(expr)
.map(str::to_owned)
.or_else(|| upsert_declared_collation(expr, existing_ctx, excluded_ctx, table))
}
fn upsert_comparison_collation(
left: &Expr,
right: &Expr,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> Option<String> {
extract_collation(left)
.or_else(|| extract_collation(right))
.map(str::to_owned)
.or_else(|| upsert_declared_collation(left, existing_ctx, excluded_ctx, table))
.or_else(|| upsert_declared_collation(right, existing_ctx, excluded_ctx, table))
}
fn upsert_expr_affinity(
expr: &Expr,
existing_ctx: &ScanCtx<'_>,
_excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> u8 {
let inner = strip_collate_wrappers(expr);
if let Expr::Column(col_ref, _) = inner
&& is_upsert_excluded_pseudo_table(col_ref, table, existing_ctx.table_alias)
{
// Like its collation, an excluded.* TK_REGISTER has no declared
// affinity. CAST remains intrinsic because it does not reach this
// direct-column branch.
b'A'
} else {
expr_affinity(expr, Some(existing_ctx))
}
}
fn upsert_comparison_affinity(
left: &Expr,
right: &Expr,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> u16 {
combine_comparison_affinity(
upsert_expr_affinity(left, existing_ctx, excluded_ctx, table),
upsert_expr_affinity(right, existing_ctx, excluded_ctx, table),
)
}
fn upsert_scalar_function_collation<'expr>(
args: impl IntoIterator<Item = &'expr Expr>,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
) -> Option<String> {
args.into_iter().find_map(|argument| {
upsert_effective_collation(argument, existing_ctx, excluded_ctx, table)
})
}
/// Emit an expression that may reference both `excluded.*` and existing row columns.
///
/// Recursively walks the expression tree, dispatching `excluded.col` references
/// to `excluded_ctx` and all other column references to `existing_ctx`. This
/// ensures that expressions like `CASE WHEN excluded.val > val THEN excluded.val
/// ELSE val END` or `coalesce(excluded.val, val)` resolve correctly in UPSERT
/// DO UPDATE SET clauses.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn emit_upsert_expr(
b: &mut ProgramBuilder,
expr: &Expr,
reg: i32,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
_table: &TableSchema,
existing_hidden_rowid_reg: Option<i32>,
excluded_hidden_rowid_reg: i32,
) {
match expr {
// ── Leaf: column reference — dispatch to correct context ────────
Expr::Column(col_ref, _) => {
let is_excluded_pseudo_ref =
is_upsert_excluded_pseudo_table(col_ref, _table, existing_ctx.table_alias);
if _table.resolves_to_hidden_rowid(&col_ref.column) && is_excluded_pseudo_ref {
b.emit_op(Opcode::Copy, excluded_hidden_rowid_reg, reg, 0, P4::None, 0);
} else if _table.resolves_to_hidden_rowid(&col_ref.column) {
if let Some(existing_hidden_rowid_reg) = existing_hidden_rowid_reg {
b.emit_op(Opcode::Copy, existing_hidden_rowid_reg, reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Rowid, existing_ctx.cursor, reg, 0, P4::None, 0);
}
} else if is_excluded_pseudo_ref {
emit_expr(b, expr, reg, Some(excluded_ctx));
} else {
emit_expr(b, expr, reg, Some(existing_ctx));
}
}
// ── Leaf: literals & placeholders — no column refs ─────────────
// (Handled by the wildcard arm)
// ── Binary operations ──────────────────────────────────────────
Expr::BinaryOp {
left, op, right, ..
} => {
use fsqlite_ast::BinaryOp;
// Pre-emit both operands with dual-context resolution.
let left_reg = b.alloc_reg();
let right_reg = b.alloc_reg();
emit_upsert_expr(
b,
left,
left_reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_expr(
b,
right,
right_reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
match op {
// Value-producing ops: arithmetic, concat, bitwise, AND, OR.
BinaryOp::Add
| BinaryOp::Subtract
| BinaryOp::Multiply
| BinaryOp::Divide
| BinaryOp::Modulo
| BinaryOp::Concat
| BinaryOp::BitAnd
| BinaryOp::BitOr
| BinaryOp::ShiftLeft
| BinaryOp::ShiftRight
| BinaryOp::And
| BinaryOp::Or => {
let opcode = binary_op_to_opcode(*op);
// VDBE: P3 = P2 op P1 → dest=reg, lhs=left, rhs=right.
b.emit_op(opcode, right_reg, left_reg, reg, P4::None, 0);
}
// Comparison ops: jump-based boolean (1/0/NULL).
BinaryOp::Eq
| BinaryOp::Ne
| BinaryOp::Lt
| BinaryOp::Le
| BinaryOp::Gt
| BinaryOp::Ge => {
let cmp_opcode = match op {
BinaryOp::Eq => Opcode::Eq,
BinaryOp::Ne => Opcode::Ne,
BinaryOp::Lt => Opcode::Lt,
BinaryOp::Le => Opcode::Le,
BinaryOp::Gt => Opcode::Gt,
BinaryOp::Ge => Opcode::Ge,
_ => unreachable!(),
};
let p4 = upsert_comparison_collation(
left,
right,
existing_ctx,
excluded_ctx,
_table,
)
.map_or(P4::None, P4::Collation);
let comparison_affinity =
upsert_comparison_affinity(left, right, existing_ctx, excluded_ctx, _table);
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, left_reg, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, right_reg, 0, null_label, P4::None, 0);
b.emit_jump_to_label(
cmp_opcode,
right_reg,
left_reg,
true_label,
p4,
comparison_affinity,
);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
}
// IS / IS NOT: check for IS TRUE/FALSE/NOT TRUE/NOT FALSE.
BinaryOp::Is | BinaryOp::IsNot => {
if let Some((p3, p4)) = is_true_false_params(*op, right) {
// Emit IsTrue opcode for IS TRUE/FALSE/NOT TRUE/NOT FALSE.
b.emit_op(Opcode::IsTrue, left_reg, reg, p3, p4, 0);
} else {
// General IS / IS NOT: NULLEQ semantics.
let true_label = b.emit_label();
let done_label = b.emit_label();
let cmp = if matches!(op, BinaryOp::Is) {
Opcode::Eq
} else {
Opcode::Ne
};
let p4 = upsert_comparison_collation(
left,
right,
existing_ctx,
excluded_ctx,
_table,
)
.map_or(P4::None, P4::Collation);
let comparison_affinity = upsert_comparison_affinity(
left,
right,
existing_ctx,
excluded_ctx,
_table,
);
b.emit_jump_to_label(
cmp,
right_reg,
left_reg,
true_label,
p4,
0x80 | comparison_affinity,
);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.resolve_label(done_label);
}
}
}
}
// ── Unary operations ───────────────────────────────────────────
Expr::UnaryOp {
op, expr: inner, ..
} => {
emit_upsert_expr(
b,
inner,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
match op {
fsqlite_ast::UnaryOp::Negate => {
let tmp = b.alloc_temp();
b.emit_op(Opcode::Integer, -1, tmp, 0, P4::None, 0);
b.emit_op(Opcode::Multiply, tmp, reg, reg, P4::None, 0);
b.free_temp(tmp);
}
fsqlite_ast::UnaryOp::Plus => {}
fsqlite_ast::UnaryOp::BitNot => {
b.emit_op(Opcode::BitNot, reg, reg, 0, P4::None, 0);
}
fsqlite_ast::UnaryOp::Not => {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
}
// ── CAST ───────────────────────────────────────────────────────
Expr::Cast {
expr: inner,
type_name,
..
} => {
emit_upsert_expr(
b,
inner,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
let affinity = type_name_to_affinity(type_name);
b.emit_op(Opcode::Cast, reg, i32::from(affinity), 0, P4::None, 0);
}
// ── IS [NOT] NULL ──────────────────────────────────────────────
Expr::IsNull {
expr: inner, not, ..
} => {
emit_upsert_expr(
b,
inner,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
let lbl_null = b.emit_label();
let lbl_done = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, reg, 0, lbl_null, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, lbl_done, P4::None, 0);
b.resolve_label(lbl_null);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.resolve_label(lbl_done);
}
// ── COLLATE ────────────────────────────────────────────────────
Expr::Collate { expr: inner, .. } => {
emit_upsert_expr(
b,
inner,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
// ── Scalar function calls ──────────────────────────────────────
// (includes multi-arg max/min which are scalar, not aggregate)
Expr::FunctionCall { name, args, .. } if !is_aggregate_function_call(name, args) => {
let canon = name.to_ascii_uppercase();
match args {
fsqlite_ast::FunctionArgs::Star => {
b.emit_op(Opcode::PureFunc, 0, 0, reg, P4::FuncName(canon), 0);
}
fsqlite_ast::FunctionArgs::List(arg_list) => {
let Ok(nargs) = u16::try_from(arg_list.len()) else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
};
let arg_base = b.alloc_regs(i32::from(nargs));
for (i, arg_expr) in arg_list.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
emit_upsert_expr(
b,
arg_expr,
arg_base + i as i32,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
let function_p4 =
if scalar_consumes_argument_collation_for_codegen(&canon, i32::from(nargs))
{
upsert_scalar_function_collation(
arg_list,
existing_ctx,
excluded_ctx,
_table,
)
.map_or_else(
|| P4::FuncName(canon.clone()),
|collation| P4::FuncNameCollated(canon.clone(), collation),
)
} else {
P4::FuncName(canon.clone())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, nargs);
}
}
}
// ── CASE expression ────────────────────────────────────────────
Expr::Case {
operand,
whens,
else_expr,
..
} => {
let done_label = b.emit_label();
let r_operand = operand.as_deref().map(|op_expr| {
let r = b.alloc_temp();
emit_upsert_expr(
b,
op_expr,
r,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
r
});
for (when_expr, then_expr) in whens {
let next_when = b.emit_label();
if let Some(r_op) = r_operand {
let r_when = b.alloc_temp();
emit_upsert_expr(
b,
when_expr,
r_when,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(Opcode::IsNull, r_op, 0, next_when, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_when, 0, next_when, P4::None, 0);
let comparison_p4 = operand
.as_deref()
.and_then(|operand| {
upsert_comparison_collation(
operand,
when_expr,
existing_ctx,
excluded_ctx,
_table,
)
})
.map_or(P4::None, P4::Collation);
let comparison_affinity = operand.as_deref().map_or(0, |operand| {
upsert_comparison_affinity(
operand,
when_expr,
existing_ctx,
excluded_ctx,
_table,
)
});
b.emit_jump_to_label(
Opcode::Ne,
r_when,
r_op,
next_when,
comparison_p4,
comparison_affinity,
);
b.free_temp(r_when);
} else {
// Searched CASE WHEN is a TRUTH context: short-circuit AND/OR
// left-to-right so a would-be-skipped erroring operand is never
// evaluated (bd-and-or-short-circuit GAP-2, ON CONFLICT DO
// UPDATE path). Non-AND/OR conditions emit byte-identically.
//
// bd-xjfrt: the condition MUST evaluate into its own scratch
// register, never `reg`. `reg` is the assignment destination,
// which for an unqualified target column aliases the existing
// row's value register (`existing_regs + col_idx`). Using `reg`
// as the comparison scratch clobbered that existing value, so a
// later bare-column reference in the THEN/ELSE (e.g.
// `CASE WHEN 1>2 THEN excluded.v ELSE v END`) read the clobbered
// 0 instead of the real column value.
let cond_scratch = b.alloc_temp();
emit_upsert_case_when_condition(
b,
when_expr,
cond_scratch,
next_when,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.free_temp(cond_scratch);
}
emit_upsert_expr(
b,
then_expr,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next_when);
}
if let Some(el) = else_expr.as_deref() {
emit_upsert_expr(
b,
el,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
b.resolve_label(done_label);
if let Some(r_op) = r_operand {
b.free_temp(r_op);
}
}
// ── LIKE / GLOB / MATCH / REGEXP ───────────────────────────────
Expr::Like {
expr: operand,
pattern,
escape,
op: like_op,
not,
..
} => {
if use_builtin_like_glob_semantics()
&& matches!(like_op, fsqlite_ast::LikeOp::Like)
&& escape.is_none()
&& let Expr::Literal(Literal::String(pattern_text), _) = pattern.as_ref()
&& let Some((kind, literal)) = classify_sql_like_fast_path(pattern_text, None)
{
emit_upsert_expr(
b,
operand,
reg,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_op(
Opcode::LikeConstFast,
reg,
reg,
kind.opcode_tag(),
P4::Str(literal.to_owned()),
u16::from(*not),
);
return;
}
let func_name = match like_op {
fsqlite_ast::LikeOp::Like => "LIKE",
fsqlite_ast::LikeOp::Glob => "GLOB",
fsqlite_ast::LikeOp::Match => "MATCH",
fsqlite_ast::LikeOp::Regexp => "REGEXP",
};
let nargs: u16 = if escape.is_some() { 3 } else { 2 };
let arg_base = b.alloc_regs(i32::from(nargs));
emit_upsert_expr(
b,
pattern,
arg_base,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_expr(
b,
operand,
arg_base + 1,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
if let Some(esc) = escape {
emit_upsert_expr(
b,
esc,
arg_base + 2,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
let function_p4 =
if scalar_consumes_argument_collation_for_codegen(func_name, i32::from(nargs)) {
upsert_scalar_function_collation(
[pattern.as_ref(), operand.as_ref()]
.into_iter()
.chain(escape.as_deref()),
existing_ctx,
excluded_ctx,
_table,
)
.map_or_else(
|| P4::FuncName(func_name.to_owned()),
|collation| P4::FuncNameCollated(func_name.to_owned(), collation),
)
} else {
P4::FuncName(func_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, nargs);
if *not {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
// ── BETWEEN ────────────────────────────────────────────────────
Expr::Between {
expr: operand,
low,
high,
not,
..
} => {
let r_operand = b.alloc_temp();
let r_low = b.alloc_temp();
let r_high = b.alloc_temp();
emit_upsert_expr(
b,
operand,
r_operand,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_expr(
b,
low,
r_low,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_expr(
b,
high,
r_high,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
let false_label = b.emit_label();
let null_label = b.emit_label();
let done_label = b.emit_label();
let low_collation_p4 =
upsert_comparison_collation(operand, low, existing_ctx, excluded_ctx, _table)
.map_or(P4::None, P4::Collation);
let high_collation_p4 =
upsert_comparison_collation(operand, high, existing_ctx, excluded_ctx, _table)
.map_or(P4::None, P4::Collation);
let low_affinity =
upsert_comparison_affinity(operand, low, existing_ctx, excluded_ctx, _table);
let high_affinity =
upsert_comparison_affinity(operand, high, existing_ctx, excluded_ctx, _table);
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
b.emit_jump_to_label(
Opcode::Lt,
r_low,
r_operand,
false_label,
low_collation_p4,
low_affinity,
);
b.emit_jump_to_label(
Opcode::Gt,
r_high,
r_operand,
false_label,
high_collation_p4,
high_affinity,
);
b.emit_jump_to_label(Opcode::IsNull, r_low, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_high, 0, null_label, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(false_label);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_high);
b.free_temp(r_low);
b.free_temp(r_operand);
}
// ── IN (list) ──────────────────────────────────────────────────
Expr::In {
expr: operand,
set,
not,
..
} => {
if let fsqlite_ast::InSet::List(values) = set {
if values.is_empty() {
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
return;
}
let r_operand = b.alloc_temp();
emit_upsert_expr(
b,
operand,
r_operand,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
let r_saw_null = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
let r_val = b.alloc_temp();
let in_collation = if values.len() == 1 && singleton_in_rhs_is_constant(&values[0])
{
upsert_comparison_collation(
operand,
&values[0],
existing_ctx,
excluded_ctx,
_table,
)
} else {
upsert_effective_collation(operand, existing_ctx, excluded_ctx, _table)
};
let comparison_p4 = in_collation.map_or(P4::None, P4::Collation);
let comparison_affinity = combine_comparison_affinity(
upsert_expr_affinity(operand, existing_ctx, excluded_ctx, _table),
b'A',
);
for val_expr in values {
emit_upsert_expr(
b,
val_expr,
r_val,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(
Opcode::Eq,
r_val,
r_operand,
true_label,
comparison_p4.clone(),
comparison_affinity,
);
let next_val = b.emit_label();
let set_flag = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_val, 0, set_flag, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_val, P4::None, 0);
b.resolve_label(set_flag);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(next_val);
}
b.free_temp(r_val);
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
b.free_temp(r_saw_null);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_operand);
} else {
// Subquery IN — unlikely in UPSERT SET, fall back to existing_ctx.
emit_expr(b, expr, reg, Some(existing_ctx));
}
}
// ── JSON access ────────────────────────────────────────────────
Expr::JsonAccess {
expr: inner,
path,
arrow,
..
} => {
let arg_base = b.alloc_regs(2);
emit_upsert_expr(
b,
inner,
arg_base,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_expr(
b,
path,
arg_base + 1,
existing_ctx,
excluded_ctx,
_table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
let function_name = json_access_func_name(*arrow);
let function_p4 = if scalar_consumes_argument_collation_for_codegen(function_name, 2) {
upsert_scalar_function_collation(
[inner.as_ref(), path.as_ref()],
existing_ctx,
excluded_ctx,
_table,
)
.map_or_else(
|| P4::FuncName(function_name.to_owned()),
|collation| P4::FuncNameCollated(function_name.to_owned(), collation),
)
} else {
P4::FuncName(function_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, 2);
}
// ── Fallback: subqueries, EXISTS, aggregates, etc. ─────────────
_ => {
emit_expr(b, expr, reg, Some(existing_ctx));
}
}
}
/// Configuration for the code generator.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CodegenContext {
/// The database text encoding. When not UTF-8, codegen disables the
/// compile-time preformatted-record bake (which assumes UTF-8 TEXT bytes) so
/// the encoding-aware runtime `MakeRecord` path serializes TEXT in the DB
/// encoding (bd-bld9w.7 family a). Defaults to UTF-8, so existing
/// construction sites via `..CodegenContext::default()` are unchanged.
pub text_encoding: TextEncoding,
/// Whether we're in `BEGIN CONCURRENT` mode.
/// When true, `OP_NewRowid` uses the snapshot-independent allocator.
pub concurrent_mode: bool,
/// Optional column index for an `INTEGER PRIMARY KEY` rowid alias on the
/// target table. Used by INSERT DEFAULT VALUES to keep the aliased column
/// in sync with the generated rowid.
pub rowid_alias_col_idx: Option<usize>,
/// Whether index-ordered scans produce correctly sorted output.
/// When false, the codegen falls back to the sorter for ORDER BY
/// instead of attempting index-assisted optimization.
/// Set to false for MemDatabase backends where indexes don't maintain
/// key-sorted iteration order.
pub index_ordered_scan_reliable: bool,
/// Optional planner-produced lowering directive for simple single-table
/// SELECT access paths. When present, lowering either honors it or emits
/// an explicit bypass reason before falling back to heuristic selection.
pub planner_select_directive: Option<SelectPlannerDirective>,
/// `PRAGMA reverse_unordered_selects`: when true, a SELECT whose row order
/// is not fixed by an ORDER BY walks its full-table scan in reverse
/// (`Last`/`Prev` instead of `Rewind`/`Next`). Only unordered scans are
/// affected; a query with an ORDER BY keeps its required order (GH #236).
pub reverse_unordered_selects: bool,
}
/// Errors during code generation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodegenError {
/// Table not found in schema.
TableNotFound(String),
/// Column not found in table.
ColumnNotFound { table: String, column: String },
/// Ambiguous unqualified column reference.
AmbiguousColumn(String),
/// Unsupported AST construct for this codegen pass.
Unsupported(String),
/// A genuine SQL statement error (e.g. an INSERT column/value count
/// mismatch) that stock SQLite reports VERBATIM under SQLITE_ERROR — not an
/// unsupported feature. Both codegen-error mappers surface it via
/// `FunctionError` (no "not implemented: " prefix).
SqlError(String),
}
impl std::fmt::Display for CodegenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TableNotFound(name) => write!(f, "table not found: {name}"),
Self::ColumnNotFound { table, column } => {
write!(f, "column {column} not found in table {table}")
}
Self::AmbiguousColumn(name) => write!(f, "ambiguous column name: {name}"),
Self::Unsupported(msg) => write!(f, "unsupported: {msg}"),
Self::SqlError(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for CodegenError {}
// ---------------------------------------------------------------------------
// Schema lookup helper
// ---------------------------------------------------------------------------
fn find_table<'a>(schema: &'a [TableSchema], name: &str) -> Result<&'a TableSchema, CodegenError> {
schema
.iter()
.find(|t| t.name.eq_ignore_ascii_case(name))
.ok_or_else(|| CodegenError::TableNotFound(name.to_owned()))
}
fn find_index_named<'a>(table: &'a TableSchema, index_name: &str) -> Option<&'a IndexSchema> {
table
.indexes
.iter()
.find(|index| index.name.eq_ignore_ascii_case(index_name))
}
fn directive_index_contract_bypass_reason(
directive: &SelectPlannerDirective,
idx_schema: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
columns: &[ResultColumn],
) -> Option<&'static str> {
let Some(expected_index_key_label) = directive.index_key_label.as_deref() else {
return Some("missing_index_key_label");
};
let Some(actual_index_key_label) = idx_schema.key_term_sql(0) else {
return Some("index_has_no_key_term");
};
if !actual_index_key_label.eq_ignore_ascii_case(expected_index_key_label) {
return Some("index_key_label_mismatch");
}
if directive.covering
&& resolve_covering_output_sources(columns, table, table_alias, idx_schema).is_none()
{
return Some("covering_contract_mismatch");
}
None
}
fn log_planner_select_directive_outcome(
directive: &SelectPlannerDirective,
honor_mode: &str,
bypass_reason: &str,
lowered_ops: &str,
) {
if !tracing::enabled!(target: "fsqlite.planner_runtime", tracing::Level::INFO) {
return;
}
let run_id = env::var("RUN_ID").unwrap_or_else(|_| "(none)".to_owned());
let trace_id = env::var("TRACE_ID").unwrap_or_else(|_| "(none)".to_owned());
let scenario_id = env::var("SCENARIO_ID").unwrap_or_else(|_| "(none)".to_owned());
let index_name = directive.index_name.as_deref().unwrap_or("(none)");
let index_key_label = directive.index_key_label.as_deref().unwrap_or("(none)");
tracing::info!(
target: "fsqlite.planner_runtime",
run_id = %run_id,
trace_id,
scenario_id = %scenario_id,
plan_id = %directive.plan_id,
plan_generation = directive.plan_generation,
planner_surface = %directive.planner_surface,
table = %directive.table_name,
index = %index_name,
index_key = %index_key_label,
access_kind = %directive.access_kind.label(),
covering = directive.covering,
honor_mode = %honor_mode,
bypass_reason = %bypass_reason,
lowered_ops = %lowered_ops,
"vdbe.planner_select_directive"
);
}
fn table_name_from_qualified(qtr: &QualifiedTableRef) -> &str {
&qtr.name.name
}
/// Emit a `SetSnapshot` opcode for cursor `cursor` if a time-travel clause
/// is present. Must be called immediately after the corresponding `OpenRead`.
fn emit_set_snapshot(b: &mut ProgramBuilder, cursor: i32, tt: Option<&TimeTravelClause>) {
if let Some(clause) = tt {
let p4 = match &clause.target {
TimeTravelTarget::CommitSequence(seq) => P4::TimeTravelCommitSeq(*seq),
TimeTravelTarget::Timestamp(ts) => P4::TimeTravelTimestamp(ts.clone()),
};
b.emit_op(Opcode::SetSnapshot, cursor, 0, 0, p4, 0);
}
}
/// Count anonymous placeholders in an expression tree.
///
/// Used by `codegen_update` to correctly number placeholders when bytecode
/// emission order differs from SQL textual order (WHERE is emitted before SET,
/// but SET placeholders appear first in the SQL text).
fn count_anon_placeholders(expr: &Expr) -> u32 {
match expr {
Expr::Placeholder(fsqlite_ast::PlaceholderType::Anonymous, _) => 1,
Expr::Placeholder(_, _)
| Expr::Literal(_, _)
| Expr::BoundOuterValue { .. }
| Expr::Column(_, _)
| Expr::Raise { .. } => 0,
Expr::Subquery(subquery, _) | Expr::Exists { subquery, .. } => {
count_anon_placeholders_in_select(subquery)
}
Expr::BinaryOp { left, right, .. } => {
count_anon_placeholders(left) + count_anon_placeholders(right)
}
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => count_anon_placeholders(inner),
Expr::Between {
expr: inner,
low,
high,
..
} => {
count_anon_placeholders(inner)
+ count_anon_placeholders(low)
+ count_anon_placeholders(high)
}
Expr::In {
expr: inner, set, ..
} => {
count_anon_placeholders(inner)
+ match set {
fsqlite_ast::InSet::List(items) => {
items.iter().map(count_anon_placeholders).sum()
}
fsqlite_ast::InSet::Subquery(subquery) => {
count_anon_placeholders_in_select(subquery)
}
fsqlite_ast::InSet::Table(_) => 0,
}
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
count_anon_placeholders(inner)
+ count_anon_placeholders(pattern)
+ escape.as_deref().map_or(0, count_anon_placeholders)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref().map_or(0, count_anon_placeholders)
+ whens
.iter()
.map(|(cond, then_expr)| {
count_anon_placeholders(cond) + count_anon_placeholders(then_expr)
})
.sum::<u32>()
+ else_expr.as_deref().map_or(0, count_anon_placeholders)
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
let args_count = match args {
FunctionArgs::List(exprs) => exprs.iter().map(count_anon_placeholders).sum(),
FunctionArgs::Star => 0,
};
args_count
+ order_by
.iter()
.map(|term| count_anon_placeholders(&term.expr))
.sum::<u32>()
+ filter.as_deref().map_or(0, count_anon_placeholders)
+ over
.as_ref()
.map_or(0, count_anon_placeholders_in_window_spec)
}
Expr::JsonAccess { expr, path, .. } => {
count_anon_placeholders(expr) + count_anon_placeholders(path)
}
Expr::RowValue(items, _) => items.iter().map(count_anon_placeholders).sum(),
}
}
fn count_anon_placeholders_in_select(select: &SelectStatement) -> u32 {
let mut count = 0;
if let Some(with_clause) = &select.with {
for cte in &with_clause.ctes {
count += count_anon_placeholders_in_select(&cte.query);
}
}
count += count_anon_placeholders_in_select_core(&select.body.select);
for (_, core) in &select.body.compounds {
count += count_anon_placeholders_in_select_core(core);
}
for order_term in &select.order_by {
count += count_anon_placeholders(&order_term.expr);
}
if let Some(limit_clause) = &select.limit {
count += count_anon_placeholders(&limit_clause.limit);
if let Some(offset) = &limit_clause.offset {
count += count_anon_placeholders(offset);
}
}
count
}
fn count_anon_placeholders_in_select_core(core: &SelectCore) -> u32 {
match core {
SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
windows,
..
} => {
let mut count = columns
.iter()
.filter_map(|col| match col {
ResultColumn::Expr { expr, .. } => Some(count_anon_placeholders(expr)),
ResultColumn::Star | ResultColumn::TableStar(_) => None,
})
.sum::<u32>();
if let Some(from_clause) = from {
count += count_anon_placeholders_in_from_clause(from_clause);
}
if let Some(predicate) = where_clause {
count += count_anon_placeholders(predicate);
}
for expr in group_by {
count += count_anon_placeholders(expr);
}
if let Some(predicate) = having {
count += count_anon_placeholders(predicate);
}
for window in windows {
count += count_anon_placeholders_in_window_spec(&window.spec);
}
count
}
SelectCore::Values(rows) => rows
.iter()
.map(|row| row.iter().map(count_anon_placeholders).sum::<u32>())
.sum(),
}
}
fn count_anon_placeholders_in_from_clause(from: &fsqlite_ast::FromClause) -> u32 {
let mut count = count_anon_placeholders_in_table_or_subquery(&from.source);
for join in &from.joins {
count += count_anon_placeholders_in_table_or_subquery(&join.table);
if let Some(fsqlite_ast::JoinConstraint::On(expr)) = &join.constraint {
count += count_anon_placeholders(expr);
}
}
count
}
fn count_anon_placeholders_in_table_or_subquery(source: &TableOrSubquery) -> u32 {
match source {
TableOrSubquery::Table { .. } => 0,
TableOrSubquery::Subquery { query, .. } => count_anon_placeholders_in_select(query),
TableOrSubquery::TableFunction { args, .. } => {
args.iter().map(count_anon_placeholders).sum()
}
TableOrSubquery::ParenJoin(from_clause) => {
count_anon_placeholders_in_from_clause(from_clause)
}
}
}
fn count_anon_placeholders_in_window_spec(spec: &fsqlite_ast::WindowSpec) -> u32 {
let mut count: u32 = spec.partition_by.iter().map(count_anon_placeholders).sum();
count += spec
.order_by
.iter()
.map(|term| count_anon_placeholders(&term.expr))
.sum::<u32>();
if let Some(frame) = &spec.frame {
count += count_anon_placeholders_in_frame_bound(&frame.start);
if let Some(end) = &frame.end {
count += count_anon_placeholders_in_frame_bound(end);
}
}
count
}
fn count_anon_placeholders_in_frame_bound(bound: &fsqlite_ast::FrameBound) -> u32 {
match bound {
fsqlite_ast::FrameBound::Preceding(expr) | fsqlite_ast::FrameBound::Following(expr) => {
count_anon_placeholders(expr)
}
fsqlite_ast::FrameBound::UnboundedPreceding
| fsqlite_ast::FrameBound::CurrentRow
| fsqlite_ast::FrameBound::UnboundedFollowing => 0,
}
}
/// Whether an expression contains a placeholder that has not been canonicalized
/// to an explicit `?NNN` slot.
///
/// Connection-level compilation canonicalizes anonymous and named parameters
/// before calling VDBE codegen. Direct codegen callers can still supply raw ASTs,
/// though, and the builder's emission-order counter cannot preserve SQL textual
/// ordering (or named-parameter reuse) when one optimization emits LIMIT before
/// WHERE. Such callers must stay on an emission path that does not duplicate or
/// reorder the expression.
fn expr_contains_non_numbered_placeholder(expr: &Expr) -> bool {
match expr {
Expr::Placeholder(fsqlite_ast::PlaceholderType::Numbered(_), _)
| Expr::Literal(_, _)
| Expr::BoundOuterValue { .. }
| Expr::Column(_, _)
| Expr::Raise { .. } => false,
Expr::Placeholder(_, _) => true,
Expr::Subquery(select, _)
| Expr::Exists {
subquery: select, ..
} => select_contains_non_numbered_placeholder(select),
Expr::BinaryOp { left, right, .. } => {
expr_contains_non_numbered_placeholder(left)
|| expr_contains_non_numbered_placeholder(right)
}
Expr::UnaryOp { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. } => expr_contains_non_numbered_placeholder(expr),
Expr::Between {
expr, low, high, ..
} => {
expr_contains_non_numbered_placeholder(expr)
|| expr_contains_non_numbered_placeholder(low)
|| expr_contains_non_numbered_placeholder(high)
}
Expr::In { expr, set, .. } => {
expr_contains_non_numbered_placeholder(expr)
|| match set {
InSet::List(items) => items.iter().any(expr_contains_non_numbered_placeholder),
InSet::Subquery(select) => select_contains_non_numbered_placeholder(select),
InSet::Table(_) => false,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
expr_contains_non_numbered_placeholder(expr)
|| expr_contains_non_numbered_placeholder(pattern)
|| escape
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
|| whens.iter().any(|(when_expr, then_expr)| {
expr_contains_non_numbered_placeholder(when_expr)
|| expr_contains_non_numbered_placeholder(then_expr)
})
|| else_expr
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
matches!(
args,
FunctionArgs::List(items)
if items.iter().any(expr_contains_non_numbered_placeholder)
) || order_by
.iter()
.any(|term| expr_contains_non_numbered_placeholder(&term.expr))
|| filter
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
|| over
.as_ref()
.is_some_and(window_spec_contains_non_numbered_placeholder)
}
Expr::JsonAccess { expr, path, .. } => {
expr_contains_non_numbered_placeholder(expr)
|| expr_contains_non_numbered_placeholder(path)
}
Expr::RowValue(items, _) => items.iter().any(expr_contains_non_numbered_placeholder),
}
}
fn select_contains_non_numbered_placeholder(select: &SelectStatement) -> bool {
select.with.as_ref().is_some_and(|with_clause| {
with_clause
.ctes
.iter()
.any(|cte| select_contains_non_numbered_placeholder(&cte.query))
}) || select_core_contains_non_numbered_placeholder(&select.body.select)
|| select
.body
.compounds
.iter()
.any(|(_, core)| select_core_contains_non_numbered_placeholder(core))
|| select
.order_by
.iter()
.any(|term| expr_contains_non_numbered_placeholder(&term.expr))
|| select.limit.as_ref().is_some_and(|clause| {
expr_contains_non_numbered_placeholder(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(expr_contains_non_numbered_placeholder)
})
}
fn select_core_contains_non_numbered_placeholder(core: &SelectCore) -> bool {
match core {
SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
windows,
..
} => {
columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. }
if expr_contains_non_numbered_placeholder(expr)
)
}) || from
.as_ref()
.is_some_and(from_clause_contains_non_numbered_placeholder)
|| where_clause
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
|| group_by.iter().any(expr_contains_non_numbered_placeholder)
|| having
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
|| windows
.iter()
.any(|window| window_spec_contains_non_numbered_placeholder(&window.spec))
}
SelectCore::Values(rows) => rows
.iter()
.flatten()
.any(expr_contains_non_numbered_placeholder),
}
}
fn from_clause_contains_non_numbered_placeholder(from: &FromClause) -> bool {
table_or_subquery_contains_non_numbered_placeholder(&from.source)
|| from.joins.iter().any(|join| {
table_or_subquery_contains_non_numbered_placeholder(&join.table)
|| matches!(
&join.constraint,
Some(fsqlite_ast::JoinConstraint::On(expr))
if expr_contains_non_numbered_placeholder(expr)
)
})
}
fn table_or_subquery_contains_non_numbered_placeholder(source: &TableOrSubquery) -> bool {
match source {
TableOrSubquery::Table { .. } => false,
TableOrSubquery::Subquery { query, .. } => select_contains_non_numbered_placeholder(query),
TableOrSubquery::TableFunction { args, .. } => {
args.iter().any(expr_contains_non_numbered_placeholder)
}
TableOrSubquery::ParenJoin(from) => from_clause_contains_non_numbered_placeholder(from),
}
}
fn window_spec_contains_non_numbered_placeholder(spec: &fsqlite_ast::WindowSpec) -> bool {
spec.partition_by
.iter()
.any(expr_contains_non_numbered_placeholder)
|| spec
.order_by
.iter()
.any(|term| expr_contains_non_numbered_placeholder(&term.expr))
|| spec.frame.as_ref().is_some_and(|frame| {
frame_bound_contains_non_numbered_placeholder(&frame.start)
|| frame
.end
.as_ref()
.is_some_and(frame_bound_contains_non_numbered_placeholder)
})
}
fn frame_bound_contains_non_numbered_placeholder(bound: &fsqlite_ast::FrameBound) -> bool {
match bound {
fsqlite_ast::FrameBound::Preceding(expr) | fsqlite_ast::FrameBound::Following(expr) => {
expr_contains_non_numbered_placeholder(expr)
}
fsqlite_ast::FrameBound::UnboundedPreceding
| fsqlite_ast::FrameBound::CurrentRow
| fsqlite_ast::FrameBound::UnboundedFollowing => false,
}
}
// ---------------------------------------------------------------------------
// SELECT codegen
// ---------------------------------------------------------------------------
/// Generate VDBE bytecode for a SELECT statement.
///
/// Handles two patterns:
/// 1. **Rowid lookup**: `SELECT cols FROM t WHERE rowid = ?`
/// 2. **Full table scan**: `SELECT cols FROM t`
///
/// Returns the cursor number used (for composability).
#[allow(clippy::too_many_lines)]
pub fn codegen_select(
b: &mut ProgramBuilder,
stmt: &SelectStatement,
schema: &[TableSchema],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
if stmt.with.is_some() {
return Err(CodegenError::Unsupported(
"WITH clauses require connection-level CTE lowering or an explicit fallback boundary"
.to_owned(),
));
}
let (columns, from, where_clause, group_by, having, distinct) = match &stmt.body.select {
SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
distinct,
..
} => (columns, from, where_clause, group_by, having, *distinct),
SelectCore::Values(rows) => {
codegen_values_select(b, rows);
return Ok(());
}
};
// Handle SELECT without FROM (e.g. SELECT 1, SELECT 1+1, SELECT abs(-5)).
if from.is_none() {
codegen_select_without_from(b, columns, where_clause.as_deref());
return Ok(());
}
// Determine the table from the FROM clause.
// SAFETY: `from.is_none()` is handled above; `.expect` cannot panic.
let from_clause = from.as_ref().expect("from already checked above");
if !from_clause.joins.is_empty()
&& let Some(plan) = grouped_inner_join_count_sum_plan(stmt, from_clause, schema)?
{
return codegen_grouped_inner_join_count_sum_select(b, &plan, ctx);
}
let (table_name, table_alias, from_schema, time_travel, from_index_hint) =
match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table {
name,
alias,
time_travel,
index_hint,
} => (
&name.name,
alias.as_deref(),
name.schema.as_deref(),
time_travel.as_ref(),
index_hint.as_ref(),
),
_ => {
return Err(CodegenError::Unsupported(
"non-table FROM source".to_owned(),
));
}
};
let join_has_time_travel = from_clause.joins.iter().any(|join| {
matches!(
&join.table,
fsqlite_ast::TableOrSubquery::Table {
time_travel: Some(_),
..
}
)
});
if !from_clause.joins.is_empty() {
let simple_join_eligible = !has_aggregate_columns(columns)
&& group_by.is_empty()
&& having.is_none()
&& !has_window_columns(columns)
&& distinct == Distinctness::All
&& time_travel.is_none()
&& !join_has_time_travel;
if !simple_join_eligible {
return Err(CodegenError::Unsupported(
"JOIN shape not yet supported in VDBE codegen".to_owned(),
));
}
}
// Route simple 2-table INNER JOINs through dedicated codegen when there
// are no aggregates, GROUP BY, window functions, or DISTINCT — this is
// the common "SELECT ... FROM a JOIN b ON ..." shape that the benchmark
// exercises and that currently falls back to the connection interpreter.
if !from_clause.joins.is_empty()
&& !has_aggregate_columns(columns)
&& group_by.is_empty()
&& having.is_none()
&& !has_window_columns(columns)
&& distinct == Distinctness::All
&& time_travel.is_none()
{
return self::codegen_join_select(
b,
stmt,
from_clause,
columns,
where_clause.as_deref(),
schema,
ctx,
);
}
let table = find_table(schema, table_name)?;
let rewritten_order_by = stmt
.order_by
.iter()
.map(|term| {
let mut rewritten = term.clone();
if resolve_order_by_output_expr(&term.expr, columns).is_none() {
rewritten.expr = rewrite_having_select_aliases(&term.expr, columns, table);
}
rewritten
})
.collect::<Vec<_>>();
if rewritten_order_by != stmt.order_by {
let mut rewritten_stmt = stmt.clone();
rewritten_stmt.order_by = rewritten_order_by;
return codegen_select(b, &rewritten_stmt, schema, ctx);
}
// bd-ujuzr: SQLite resolves a result-column alias referenced in the WHERE
// clause when no real table column matches. Substitute aliases up front (a
// real column always wins), reusing the HAVING alias-rewrite, so both
// validation and the scan filter see the underlying expression.
let where_clause_rewritten: Option<Box<Expr>> = where_clause
.as_deref()
.map(|w| Box::new(rewrite_having_select_aliases(w, columns, table)));
// Re-bind with the original `&Option<Box<Expr>>` shape so the downstream
// `.as_deref()` call sites keep their meaning (and avoid no-op derefs).
let where_clause = &where_clause_rewritten;
validate_single_table_result_columns(columns, table, table_alias, from_schema)?;
if let Some(where_expr) = where_clause.as_deref() {
validate_single_table_expr_columns(where_expr, table, table_alias)?;
}
validate_single_table_order_by_terms(&stmt.order_by, columns, table, table_alias)?;
let cursor = 0_i32;
// Labels for control flow.
let end_label = b.emit_label();
let done_label = b.emit_label();
// Init: jump to end (standard SQLite pattern).
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (read-only, p2=0).
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
// Determine output columns and allocate registers.
let out_col_count = result_column_count(columns, table);
let out_regs = b.alloc_regs(out_col_count);
let simple_count_star = is_simple_count_star(columns)
&& stmt.limit.is_none()
&& stmt.order_by.is_empty()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& !has_window_columns(columns)
&& from_clause.joins.is_empty()
&& time_travel.is_none()
// bd-2dgf5: `COUNT(*) FROM t WHERE col = <const>` (equality) or `col IN (<int list>)` on an
// indexed column is served far better by the aggregate index seek than by
// `codegen_select_count_star`, whose only indexed path
// (`extract_count_indexed_exists_target`) does not match a plain equality or an IN-list and so
// degrades to Rewind/Next over the whole table. Yield to `codegen_select_aggregate`, which drives
// AggStep from an index seek (`index_eq_seek` / `index_in_seek`). Every other COUNT(*) shape keeps
// its specialized fast path.
&& !(aggregate_index_eq_seek_allowed(from_index_hint)
&& (aggregate_index_eq_seek_target(where_clause.as_deref(), table, table_alias).is_some()
|| index_integer_in_list_residual_target(where_clause.as_deref(), table, table_alias)
.is_some()
|| aggregate_index_range_seek_target(
where_clause.as_deref(),
table,
table_alias,
schema,
)
.is_some()
|| composite_index_prefix_range_target(
where_clause.as_deref(),
table,
table_alias,
schema,
None,
)
.is_some()
|| aggregate_index_prefix_literal_residual_target(
where_clause.as_deref(),
table,
table_alias,
None,
)
.is_some()
|| extract_rowid_range_residual_target(where_clause.as_deref(), table, table_alias)
.is_some_and(|(_, has_residual)| has_residual)));
// Check for aggregate columns FIRST, before rowid/index seek optimizations.
// Most aggregates still require a full scan + AggStep/AggFinal path. Plain
// COUNT(*) is handled separately below so it can keep a specialized fast path.
let is_aggregate = has_aggregate_columns(columns);
let forced_index_hint = matches!(from_index_hint, Some(fsqlite_ast::IndexHint::IndexedBy(_)));
// Check for rowid-equality WHERE clause (only for non-aggregate queries).
let rowid_target = if is_aggregate || forced_index_hint {
None
} else {
extract_rowid_target_expr(where_clause.as_deref(), Some(table), table_alias)
};
let rowid_order = (!stmt.order_by.is_empty())
.then(|| resolve_order_by_rowid_direction(table, table_alias, columns, &stmt.order_by))
.flatten();
let rowid_range_allowed = !(forced_index_hint
|| (is_aggregate && !simple_count_star)
|| distinct != Distinctness::All
|| !group_by.is_empty()
|| having.is_some()
|| (!stmt.order_by.is_empty() && rowid_order.is_none()));
let rowid_range = if rowid_range_allowed {
extract_rowid_range_target(where_clause.as_deref(), Some(table), table_alias)
.and_then(|range| rowid_range_fast_path_is_safe(range).then_some(range))
} else {
None
};
// bd-nonagg-rowid-range-residual: `rowid <range> AND <residual>` — walk only the [lower, upper] slice
// (rowid order = full-scan order) and re-apply the whole WHERE per row, instead of full-scanning. The
// bare range (no residual) is owned by `rowid_range` above; this handles ONLY the conjunction case
// (`has_residual`). Gated to no-LIMIT so nothing before the residual filter emits a placeholder (the
// detector already requires integer-literal bounds), keeping the residual `?` numbering trivial.
let rowid_range_residual =
if rowid_range_allowed && rowid_range.is_none() && stmt.limit.is_none() {
extract_rowid_range_residual_target(where_clause.as_deref(), table, table_alias)
.filter(|(_, has_residual)| *has_residual)
.map(|(range, _)| range)
} else {
None
};
let index_range = if is_aggregate
|| from_index_hint.is_some()
|| time_travel.is_some()
|| distinct != Distinctness::All
|| !group_by.is_empty()
|| having.is_some()
{
None
} else {
extract_column_range_target(where_clause.as_deref(), table, table_alias).and_then(
|(col_name, range)| {
// All-index search: a composite `(col, …)` index declared before `idx_col` shadows it
// in `index_for_column`, and filtering that result with `?` would decline even though a
// usable single-column index exists — full-scanning `WHERE col <range>` (non-aggregate
// mirror of bd-agg-range-shadowed-index). Search every index instead.
let idx = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
})?;
// bd-wimmv/bd-ss48y follow-up: the single-column range seek streams in `(col, rowid)`
// order (single-key index), so it satisfies a deterministic `ORDER BY col, <rowid>`
// (or bare `ORDER BY col` when the index is UNIQUE — no ties) without a sorter —
// otherwise it falls to a sorter. Any other ORDER BY declines.
if !stmt.order_by.is_empty()
&& !range_order_by_is_deterministic(
&col_name,
&stmt.order_by,
table,
table_alias,
idx.is_unique,
false,
)
{
return None;
}
if !index_range_fast_path_is_safe(table, table_alias, schema, &col_name, &range) {
return None;
}
Some((col_name, idx, range))
},
)
};
// Check for a simple indexed equality probe (only for non-aggregate queries).
// We probe with [bound_value, i64::MIN] so SeekGE anchors on the first
// duplicate entry in non-unique indexes and the loop can walk the full
// duplicate run via Next + IdxRowid.
let index_eq_target = if is_aggregate {
None
} else {
extract_column_eq_target(where_clause.as_deref(), table, table_alias)
};
// Heuristic equality lowering must never compete with an explicit table hint. Resolve the exact
// usable index once, including collation and physical-shape checks, and carry that same object into
// emission. This prevents validating one index and then opening a different first-match index.
// The current equality extractor accepts only a bare column paired with a simple constant, so the
// comparison collation is exactly the column's declared collation; explicit COLLATE shapes decline.
let heuristic_index_eq: Option<(&IndexSchema, &Expr)> = if from_index_hint.is_some() {
None
} else {
index_eq_target
.as_ref()
.and_then(|(column_name, target_expr)| {
let comparison_collation = table
.column_index(column_name)
.and_then(|column_idx| table.columns.get(column_idx))
.and_then(|column| column.collation.as_deref());
table
.single_column_index_for_column_with_collation(
column_name,
comparison_collation,
)
.map(|index| (index, *target_expr))
})
};
// bd-nonagg-eq-residual: `SELECT <cols> FROM t WHERE <int/text-indexed col> = <lit>
// AND <residual on a non-indexed col>` (e.g. `SELECT * ... WHERE a = 5 AND c = 7`). The planner
// emits an IndexEquality directive for the `col=lit` conjunct which bypasses on the residual it
// cannot enforce (or emits none), reaching the heuristic chain below — where `index_eq_target`'s bare-`Eq`
// detection misses the conjunction and the query full-scans. Detect the exact eq-literal prefix (via
// the residual-safe `aggregate_index_prefix_literal_residual_target`, which requires ≥1 residual
// conjunct so pure `col=lit` still routes through the directive) and seek the `col=lit` block,
// filtering the FULL WHERE per row (`codegen_select_index_equality_scan` with `residual_filter =
// true`, so its fast path narrows to the exact matches). Dispatched as a heuristic fallback (AFTER
// rowid/range/index_eq, before the full scan) so strictly-better rowid/range paths still win. Single
// eq column only. The residual-aware emitter always opens the table even when the projection itself
// is index-covering, because the residual may read any table column. The `col=lit` rows come back in
// rowid order — the full scan's order within that block — so byte-identical.
let eq_residual: Option<(&IndexSchema, &Expr)> = if is_aggregate
|| from_index_hint.is_some()
|| time_travel.is_some()
|| !stmt.order_by.is_empty()
|| distinct != Distinctness::All
|| !group_by.is_empty()
|| having.is_some()
|| has_window_columns(columns)
|| table.without_rowid
// The connection canonicalizes every bind parameter to `?NNN`, which
// keeps the residual and LIMIT/OFFSET slots stable even though this
// emitter generates LIMIT first and WHERE later. A direct/raw AST can
// still contain anonymous or named parameters; decline only this
// reorder-and-reapply optimization rather than assigning colliding
// slots or breaking named-parameter reuse.
|| where_clause
.as_deref()
.is_some_and(expr_contains_non_numbered_placeholder)
|| stmt.limit.as_ref().is_some_and(|clause| {
expr_contains_non_numbered_placeholder(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(expr_contains_non_numbered_placeholder)
}) {
None
} else {
aggregate_index_prefix_literal_residual_target(
where_clause.as_deref(),
table,
table_alias,
Some(1),
)
.filter(|(_idx, prefix)| prefix.len() == 1)
.map(|(idx, prefix)| (idx, prefix[0]))
};
// bd-2dgf5: non-aggregate `SELECT ... FROM t WHERE <int col> IN (<int literals>)` seeks
// the index once per distinct value instead of full-scanning. Same INTEGER-affinity +
// integer-literal safe subset as the aggregate IN path; declined for ORDER BY (the seek
// yields value-then-rowid order, which only happens to equal the sort order sometimes),
// LIMIT, and DISTINCT to keep this first cut small.
let in_list_seek_allowed = !is_aggregate
&& from_index_hint.is_none()
&& stmt.order_by.is_empty()
&& stmt.limit.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& !table.without_rowid;
// bd-nonagg-rowid-in-order: the rowid IN seek emits in ascending-rowid order (the values are sorted
// and deduped), so it can serve `ORDER BY <rowid> ASC` for free — and `DESC` by reversing the emit
// order — instead of declining to a full-scan-plus-sort. `rowid_order` (computed above) is `Some`
// ONLY for a single ORDER BY term that resolves to the rowid; the IPK is unique so no tiebreaker is
// needed. Unlike the index IN seek (value-then-rowid order), the rowid seek's order is exactly rowid
// order, so this relaxation is rowid-IN-specific — `in_list_seek_allowed` stays strict for index_in.
let rowid_in_seek_allowed = !is_aggregate
&& from_index_hint.is_none()
&& (stmt.order_by.is_empty() || rowid_order.is_some())
&& stmt.limit.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& !table.without_rowid;
// bd-2dgf5: `WHERE <rowid> IN (<int literals>)` — one SeekRowid per distinct value.
// bd-nonagg-rowid-in-residual: also admit `rowid IN (ints) AND <residual>` — the residual variant
// returns `has_residual = true`; the SeekRowid probes visit only the listed rows and the emitter
// re-applies the whole WHERE per row. The table is always open, so the residual reads any column.
let rowid_in = if rowid_in_seek_allowed {
extract_rowid_in_list_residual_target(where_clause.as_deref(), table, table_alias)
} else {
None
};
// bd-nonagg-in-list-residual: also admit `col IN (ints) AND <residual>` — the residual variant
// returns `has_residual = true`, and the IN scan re-applies the whole WHERE per row (the seek
// visits the IN runs, a superset; the residual narrows to exact). The IN emitter always opens the
// table, so the residual reads any column and no covering gate is needed; IN is not a single-eq
// prefix, so it does not collide with the composite-prefix-range path.
let index_in = if in_list_seek_allowed && rowid_in.is_none() {
index_integer_in_list_residual_target(where_clause.as_deref(), table, table_alias)
} else {
None
};
// bd-nonagg-rowid-eq-residual: `rowid = <const> AND <residual>` — one SeekRowid on the target row,
// then the whole WHERE re-applied to it. The planner emits a RowidLookup directive (it sees the eq)
// but codegen's bare `rowid_target` extraction declines the conjunction, so the directive bypasses
// (`rowid_lookup_target_missing`) and the shape falls through to a full scan. Route it here, before
// the directive. The bare `rowid = <const>` (single conjunct) is declined by the detector and keeps
// its existing directive path. Same narrow gate as the IN seeks. `codegen_select_rowid_lookup`
// always opens the table, so the residual reads any column and all outputs work.
let rowid_eq_residual = if in_list_seek_allowed && rowid_in.is_none() {
extract_rowid_eq_residual_target(where_clause.as_deref(), table, table_alias)
} else {
None
};
// bd-2dgf5: route a seekable integer IN-list BEFORE the planner directive. The connection
// emits a `FullTableScan` directive for `rowid IN (...)` (it does not model IN as a
// seekable access path), which would otherwise bypass these seeks. The gate is narrow
// (INTEGER-affinity + integer literals + IN, no unsupported ORDER BY / LIMIT / DISTINCT):
// a single rowid ASC/DESC ordering is served directly by the sorted seek sequence. The
// seek is differential-proven bit-identical to SQLite, so preferring it over a full scan
// is always correct and strictly faster.
if let Some((values, has_residual)) = rowid_in {
return codegen_select_rowid_in_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
&values,
where_clause.as_deref(),
has_residual,
matches!(rowid_order, Some(SortDirection::Desc)),
);
}
// bd-nonagg-rowid-eq-residual: single SeekRowid + full-WHERE residual, ahead of the directive.
if let Some(target_expr) = rowid_eq_residual {
return codegen_select_rowid_lookup(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
stmt.limit.as_ref(),
target_expr,
where_clause.as_deref(),
true,
);
}
if let Some((idx_schema, values, has_residual)) = index_in {
return codegen_select_index_in_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
&values,
where_clause.as_deref(),
has_residual,
);
}
// A rowid-backed IN subquery over an indexed outer column can be lowered to
// a streaming semijoin that counts each equal first-key run in the outer
// index. Route that proven shape before the planner directive: the planner
// currently models IN as a scan and an explicit INDEXED BY hint therefore
// otherwise commits us to the generic per-row membership program. The
// extractor honors INDEXED BY/NOT INDEXED and admits a composite index only
// when the unique, ordered RHS rowid stream makes the merge path mandatory;
// list/materialized probes retain their single-key seek contract.
if simple_count_star {
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
if let Some((idx_schema, in_target)) = extract_count_indexed_in_target(
where_clause.as_deref(),
table,
table_alias,
schema,
&scan_ctx,
from_index_hint,
) {
return codegen_select_count_star_indexed_in_scan(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
idx_schema,
in_target,
);
}
}
// bd-g5ys1: route `SELECT <cols> FROM t WHERE <indexed col> IN (SELECT ...)`
// (uncorrelated, single-key ascending index, matching collation) through a
// materialize-then-seek program instead of the full scan probing an
// ephemeral membership index. Same pre-directive routing rationale as the
// IN-list and COUNT(*) hooks above: the planner models IN as a scan (its
// IndexEquality directive carries no payload for an IN), so the directive
// arm declines and the shape previously fell to `codegen_select_full_scan`.
// The gate reuses `in_list_seek_allowed` (non-aggregate, no ORDER BY /
// LIMIT / DISTINCT / GROUP BY / HAVING, rowid table); the extractor
// enforces plain `IN` (not `NOT IN`), an uncorrelated subquery, index-hint
// honor, and operand/index collation equivalence. Control reaching here
// means the rowid/IN-list routes above did not fire. The `(key, i64::MIN)`
// seek-probe contract requires a single-key index, so composite matches
// (admitted by the extractor only for the COUNT merge path) are declined.
if in_list_seek_allowed {
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
if let Some((idx_schema, in_target)) = extract_count_indexed_in_target(
where_clause.as_deref(),
table,
table_alias,
schema,
&scan_ctx,
from_index_hint,
) && idx_schema.key_term_count() == 1
&& let CountIndexedInTarget::ProbeSource(probe_source)
| CountIndexedInTarget::MaterializedProbeSource(probe_source) = in_target
{
return codegen_select_index_in_subquery_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
&probe_source,
);
}
}
// bd-zqkrp: route a composite equality-prefix + trailing-range seek (`WHERE a = v AND b <range>`
// on `index(a, b)`) BEFORE the planner directive. The planner reports this as an IndexRange
// directive, but the single-column `index_range` local below is None for this shape, so the
// directive bypasses to a full scan. The seek is affinity-coerced and differential-proven
// bit-identical, so preferring it is always correct and strictly faster. First cut declines
// ORDER BY / LIMIT / DISTINCT / aggregate / GROUP BY / WITHOUT ROWID.
//
// GH #291: an `INDEXED BY` hint naming a composite index MUST take this
// seek — previously any hint declined it, and because the IndexRange
// directive arm also rejects composite shapes, the hinted query fell all
// the way back to a full table scan (observed as multi-GB RSS for a
// 593-row range lookup on a 9 GB database). The hint now restricts the
// candidate set to the named index; `NOT INDEXED` still declines by
// definition.
let composite_required_index = match from_index_hint {
None => Some(None),
Some(fsqlite_ast::IndexHint::IndexedBy(name)) => Some(Some(name.as_str())),
Some(fsqlite_ast::IndexHint::NotIndexed) => None,
};
let composite_prefix_range = if !is_aggregate
&& time_travel.is_none()
&& let Some(required_index) = composite_required_index
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& !table.without_rowid
{
composite_index_prefix_range_target(
where_clause.as_deref(),
table,
table_alias,
schema,
required_index,
)
} else {
None
};
// bd-zqkrp follow-up: the seek emits rows in `(range_col, rowid)` order, so it satisfies an
// `ORDER BY <trailing range col> ASC` (when that column is the last key term) without a sorter —
// and LIMIT/OFFSET streams straight off the seek. An empty ORDER BY always qualifies.
if let Some(composite) = composite_prefix_range {
if stmt.order_by.is_empty()
|| composite_order_by_satisfied(&composite, &stmt.order_by, table, table_alias)
{
return codegen_select_composite_index_prefix_range_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
composite.index,
&composite.prefix_exprs,
&composite.range,
);
}
// bd-6x9z0 follow-up: composite DESC. `WHERE a = v AND b <range> ORDER BY b DESC, id DESC`
// (composite keyset "most recent first" pagination) streams off a reverse index walk with NO
// sorter — the composite mirror of the single-column DESC range seek. Only the deterministic
// `range_col DESC[, id DESC]` order (or a bare `range_col DESC` on a UNIQUE index) qualifies;
// any other order falls through to the sorter.
if composite_order_by_satisfied_desc(&composite, &stmt.order_by, table, table_alias) {
return codegen_select_composite_index_prefix_range_scan_desc(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
composite.index,
&composite.prefix_exprs,
&composite.range,
);
}
}
// bd-ln7dp: reverse (DESC) single-column range seek. `WHERE col <range> ORDER BY col DESC, id
// DESC` (keyset "most recent first" pagination) streams in `(col DESC, rowid DESC)` order off a
// reverse index walk (SeekLE/Last + Prev) — no sorter, and LIMIT/OFFSET stream off it. Routed
// before the directive (which would otherwise pick the ascending seek). Declines aggregate /
// GROUP BY / DISTINCT / hints / WITHOUT ROWID and any order other than `col DESC[, id DESC]`.
let index_range_desc = if !is_aggregate
&& time_travel.is_none()
&& from_index_hint.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& !table.without_rowid
&& !stmt.order_by.is_empty()
{
extract_column_range_target(where_clause.as_deref(), table, table_alias).and_then(
|(col_name, range)| {
// All-index search (see the ascending `index_range` above): don't let a composite
// `(col, …)` index shadow a usable single-column one and force a full scan.
let idx = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
})?;
if !range_order_by_is_deterministic(
&col_name,
&stmt.order_by,
table,
table_alias,
idx.is_unique,
true,
) {
return None;
}
if !index_range_fast_path_is_safe(table, table_alias, schema, &col_name, &range) {
return None;
}
Some((idx, range))
},
)
} else {
None
};
if let Some((idx_schema, range)) = index_range_desc {
return codegen_select_index_range_scan_desc(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
&range,
);
}
// bd-nax2y skip scan: `SELECT <cols> FROM t WHERE <second_col> = <const>` where the constrained
// column is the SECOND term of a two-column index whose LEADING term is unconstrained (a
// MySQL-style skip scan). No constraint on the leading term, so the equality/range/composite seeks
// above all decline and the query would otherwise full-scan. The emitter is adaptive so a
// near-unique leading column degrades to a full index walk (== the covering scan it replaces).
// First cut: no ORDER BY / LIMIT / GROUP BY / HAVING / DISTINCT / aggregate / index hint.
if !is_aggregate
&& time_travel.is_none()
&& from_index_hint.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& let Some(skip) = skip_scan_eq_target(where_clause.as_deref(), table, table_alias)
// ORDER BY `x, id` (the 2nd column is a constant here) is served in the emission order with no
// sorter, and a satisfied ORDER BY makes a LIMIT's top-N deterministic so it may stream too. A
// bare LIMIT with no ORDER BY (non-deterministic which-rows) declines.
&& ((stmt.order_by.is_empty() && stmt.limit.is_none())
|| skip_scan_order_by_satisfied(skip.index, false, &stmt.order_by, table, table_alias))
{
return codegen_select_skip_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
skip.index,
skip.const_expr,
stmt.limit.as_ref(),
);
}
// Range skip scan (bd-nax2y): the same shape but the constrained second key term carries an
// inclusive-lower range instead of an equality.
if !is_aggregate
&& time_travel.is_none()
&& from_index_hint.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& let Some(skip) = skip_scan_range_target(where_clause.as_deref(), table, table_alias)
// ORDER BY `x, second, id` streams with no sorter, and a satisfied ORDER BY makes a LIMIT's
// top-N deterministic so it may stream too. A bare LIMIT with no ORDER BY declines.
&& ((stmt.order_by.is_empty() && stmt.limit.is_none())
|| skip_scan_order_by_satisfied(skip.index, true, &stmt.order_by, table, table_alias))
{
return codegen_select_skip_scan_range(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
skip.index,
&skip.range,
stmt.limit.as_ref(),
);
}
// IS-NULL skip scan (bd-nax2y): `WHERE <second_col> IS NULL`; the 2nd key is constant (NULL) within
// the run, so ORDER BY treats it like the equality case (`second_varies = false`).
if !is_aggregate
&& time_travel.is_none()
&& from_index_hint.is_none()
&& distinct == Distinctness::All
&& group_by.is_empty()
&& having.is_none()
&& let Some(skip) = skip_scan_is_null_target(where_clause.as_deref(), table, table_alias)
&& ((stmt.order_by.is_empty() && stmt.limit.is_none())
|| skip_scan_order_by_satisfied(skip.index, false, &stmt.order_by, table, table_alias))
{
return codegen_select_skip_scan_is_null(
b,
cursor,
table,
table_alias,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
skip.index,
stmt.limit.as_ref(),
);
}
// bd-rowid-range-before-directive: `SELECT ... FROM t WHERE <rowid> <range>` receives a
// `FullTableScan` planner directive — `PlannerSelectAccessKind` has no rowid-range variant, so the
// planner cannot model it as a seekable path — which short-circuits to `codegen_select_full_scan`
// below and reads every row, bypassing the `rowid_range` heuristic (only reached with no
// directive). The rowid-range scan positions on the table b-tree at the bound and walks only the
// `[lower, upper]` rowid slice in rowid order — the SAME order the full scan emits — so it is
// byte-identical to the full scan (same rows, same order, just fewer) and always ≤ it: no covering
// gate is needed because it reads the table directly, exactly like the scan, only bounded. Route it
// before the directive, mirroring the bd-2dgf5 / bd-zqkrp seeks. `rowid_range` is `Copy`, so the
// heuristic dispatch below is left intact for the no-directive path. bd-rowid-range-before-directive.
if let Some(rr) = rowid_range
&& ctx.planner_select_directive.as_ref().is_some_and(|d| {
d.table_name.eq_ignore_ascii_case(&table.name)
&& matches!(d.access_kind, PlannerSelectAccessKind::FullTableScan)
})
{
return codegen_select_rowid_range_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
rr,
matches!(rowid_order, Some(SortDirection::Desc)),
None,
false,
);
}
// bd-nonagg-rowid-range-residual: same pre-directive hoist as the bare range above, but for
// `rowid <range> AND <residual on a column the planner cannot seek>`. The planner still emits a
// `FullTableScan` directive (it models neither the rowid range nor the non-indexed residual as
// seekable), which would full-scan every row. The residual walk visits only the [lower, upper] slice
// in rowid order and filters per row — byte-identical to the full scan, always ≤ it, so no covering
// gate. (When the residual IS on an indexed column the planner emits an IndexEquality directive
// instead and that path — with its own residual filter — owns the query; this hoist declines.)
if let Some(rr) = rowid_range_residual
&& ctx.planner_select_directive.as_ref().is_some_and(|d| {
d.table_name.eq_ignore_ascii_case(&table.name)
&& matches!(d.access_kind, PlannerSelectAccessKind::FullTableScan)
})
{
return codegen_select_rowid_range_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
rr,
matches!(rowid_order, Some(SortDirection::Desc)),
where_clause.as_deref(),
true,
);
}
// bd-covering-range-before-directive: `SELECT <covering cols> FROM t WHERE col <range>` (no
// ORDER BY) receives a `FullTableScan` planner directive — the planner does not model a
// single-column range as a seekable access path — which short-circuits to
// `codegen_select_full_scan` below and reads every row, bypassing the `index_range` heuristic (only
// reached when no directive is emitted). When the seek is COVERING (every output is the rowid or an
// index column, so `resolve_covering_output_sources` succeeds and no table row is ever read) the
// index-range walk visits only the matching key slice and is strictly cheaper than the full scan —
// it cannot pessimize even a non-selective range the way a non-covering per-row `SeekRowid` could.
// Route it before the directive, exactly like the bd-2dgf5 IN-list and bd-zqkrp composite-prefix
// seeks; the emitted rows are index-ordered (spec-legal without ORDER BY) and byte-identical as a
// set to the full scan (same oracle-tested emitter used at the heuristic below). Rowid tables only;
// non-covering ranges are left to the planner's full scan to avoid random-lookup regressions.
if let Some((_range_col, idx_schema, range)) = index_range.as_ref()
&& !table.without_rowid
&& ctx.planner_select_directive.as_ref().is_some_and(|d| {
d.table_name.eq_ignore_ascii_case(&table.name)
&& matches!(d.access_kind, PlannerSelectAccessKind::FullTableScan)
})
&& resolve_covering_output_sources(columns, table, table_alias, idx_schema).is_some()
{
return codegen_select_index_range_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
planner_index_range_target_from_column_range(range),
);
}
if let Some(directive) = ctx.planner_select_directive.as_ref() {
let bypass_reason = if !directive.table_name.eq_ignore_ascii_case(&table.name) {
Some("table_mismatch")
} else {
match directive.access_kind {
PlannerSelectAccessKind::FullTableScan => {
// bd-gh-indexed-by-desc-scan-order (#224): `INDEXED BY <idx>`
// is a HARD contract. The planner directive falls back to a
// FullTableScan when no WHERE made the forced index seekable,
// but the forced index must still be honored as a full ordered
// index scan in its STORED order (forward Rewind/Next: ascending
// for an ASC index, descending for a DESC index), matching C
// SQLite, rather than silently table-scanning in rowid order.
if let Some(fsqlite_ast::IndexHint::IndexedBy(hinted)) = from_index_hint
&& stmt.order_by.is_empty()
&& let Some(forced_idx) = table
.indexes
.iter()
.find(|idx| idx.name.eq_ignore_ascii_case(hinted))
&& forced_idx.supports_direct_column_lookup()
{
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"forced_index_full_scan",
);
let plan = OrderByIndexPlan {
index: forced_idx.clone(),
descending: false,
equality_prefix_len: 0,
covering_output: resolve_covering_output_sources(
columns,
table,
table_alias,
forced_idx,
),
};
return codegen_select_index_ordered_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
&plan,
);
}
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"full_table_scan",
);
return codegen_select_full_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
// reverse_unordered_selects: reverse this scan only when
// the row order is not fixed by an ORDER BY (GH #236).
ctx.reverse_unordered_selects && stmt.order_by.is_empty(),
);
}
PlannerSelectAccessKind::RowidLookup => match rowid_target {
Some(target_expr) => {
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"seek_rowid",
);
return codegen_select_rowid_lookup(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
stmt.limit.as_ref(),
target_expr,
None,
false,
);
}
None => Some("rowid_lookup_target_missing"),
},
PlannerSelectAccessKind::IndexEquality => {
if let Some(index_name) = directive.index_name.as_deref() {
if let Some(idx_schema) = find_index_named(table, index_name) {
if let Some(reason) = directive_index_contract_bypass_reason(
directive,
idx_schema,
table,
table_alias,
columns,
) {
Some(reason)
} else if directive.index_key_is_expression {
if let Some(directive_target_expr) =
directive.index_equality_target.as_ref()
{
match extract_expression_index_equality_expr(
where_clause.as_deref(),
idx_schema,
table,
table_alias,
) {
Some(actual_target_expr)
if actual_target_expr == directive_target_expr =>
{
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"index_equality_probe",
);
return codegen_select_index_equality_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
directive_target_expr,
false,
false,
);
}
Some(_) => Some("index_equality_target_mismatch"),
None => Some("index_equality_target_missing"),
}
} else {
Some("index_equality_target_missing")
}
} else if let Some((index_column_name, target_expr)) =
index_eq_target.as_ref()
{
if directive.index_key_label.as_deref().is_none_or(|label| {
!label.eq_ignore_ascii_case(index_column_name)
}) {
Some("index_key_label_mismatch")
} else {
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"index_equality_probe",
);
return codegen_select_index_equality_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
target_expr,
false,
false,
);
}
} else if !directive.index_key_is_expression
&& let Some(label) = directive.index_key_label.as_deref()
&& let Some(conjunct_target_expr) =
extract_labeled_eq_conjunct_target(
where_clause.as_deref(),
table,
table_alias,
label,
)
{
// bd-kwaam (#377): the directive's key column matched one
// conjunct of a larger AND-tree. Lower through the same seek
// emitter with the FULL WHERE re-applied per row
// (`residual_filter = true`), so the remaining conjuncts
// stay enforced.
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"index_equality_probe_residual",
);
return codegen_select_index_equality_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
conjunct_target_expr,
true,
false,
);
} else {
Some("index_equality_target_missing")
}
} else {
Some("index_not_found")
}
} else {
Some("missing_index_name")
}
}
PlannerSelectAccessKind::IndexRange => {
if let Some(index_name) = directive.index_name.as_deref() {
if let Some(idx_schema) = find_index_named(table, index_name) {
if idx_schema.key_term_count() != 1 || idx_schema.key_term_descending(0)
{
Some("index_shape_unsupported")
} else if let Some(reason) = directive_index_contract_bypass_reason(
directive,
idx_schema,
table,
table_alias,
columns,
) {
Some(reason)
} else if directive.index_key_is_expression {
if let Some(directive_range_target) =
directive.index_range_target.as_ref()
{
match extract_expression_index_range_target(
where_clause.as_deref(),
idx_schema,
table,
table_alias,
) {
Some(actual_range_target)
if &actual_range_target == directive_range_target =>
{
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"index_range_scan",
);
return codegen_select_index_range_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
directive_range_target.clone(),
);
}
Some(_) => Some("index_range_target_mismatch"),
None => Some("index_range_target_missing"),
}
} else {
Some("index_range_target_missing")
}
} else if let Some((index_column_name, _candidate_idx, range_target)) =
index_range.as_ref()
{
if directive.index_key_label.as_deref().is_none_or(|label| {
!label.eq_ignore_ascii_case(index_column_name)
}) {
Some("index_key_label_mismatch")
} else {
log_planner_select_directive_outcome(
directive,
"honored",
"none",
"index_range_scan",
);
return codegen_select_index_range_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
planner_index_range_target_from_column_range(range_target),
);
}
} else {
Some("index_range_target_missing")
}
} else {
Some("index_not_found")
}
} else {
Some("missing_index_name")
}
}
}
};
if let Some(reason) = bypass_reason {
log_planner_select_directive_outcome(
directive,
"bypassed",
reason,
"heuristic_fallback",
);
}
}
if simple_count_star {
codegen_select_count_star(
b,
cursor,
table,
table_alias,
schema,
where_clause.as_deref(),
out_regs,
done_label,
end_label,
rowid_range,
from_index_hint,
)
} else if let Some(target_expr) = rowid_target {
codegen_select_rowid_lookup(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
out_regs,
out_col_count,
done_label,
end_label,
stmt.limit.as_ref(),
target_expr,
None,
false,
)
} else if let Some(rowid_range) = rowid_range {
codegen_select_rowid_range_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
rowid_range,
matches!(rowid_order, Some(SortDirection::Desc)),
None,
false,
)
} else if let Some(rr) = rowid_range_residual {
// bd-nonagg-rowid-range-residual: the planner SUPPRESSES the directive for an IPK range (returns
// None) so the VDBE picks its bounded seek here in the heuristic fallback — same path the bare
// range uses. Walk the [lower, upper] slice and re-apply the whole WHERE per row.
codegen_select_rowid_range_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
rr,
matches!(rowid_order, Some(SortDirection::Desc)),
where_clause.as_deref(),
true,
)
} else if let Some((_index_column_name, idx_schema, index_range)) = index_range {
codegen_select_index_range_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
planner_index_range_target_from_column_range(&index_range),
)
} else if let Some((idx_schema, target_expr)) = heuristic_index_eq.filter(|_| {
// bd-nonagg-index-eq-order-rowid: the index-equality seek returns rows in index-key order — for a
// SINGLE-column ASCENDING index that is exactly rowid order WITHIN the one eq value (ascending
// seeks `(val, i64::MIN)` + walk forward; descending seeks `(val, i64::MAX)` + `Prev`), so it
// also satisfies `ORDER BY <rowid>` ASC or DESC for free, seeking only the matching rows instead
// of full-scanning + sorting. A composite index would order by its trailing key column, so it
// declines and keeps the sorter / plain scan. No ORDER BY is always fine.
stmt.order_by.is_empty() || rowid_order.is_some()
}) {
// --- Index-seek SELECT: no ORDER BY, or `ORDER BY <rowid>` served by the seek's key order.
let idx_desc = matches!(rowid_order, Some(SortDirection::Desc));
codegen_select_index_equality_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
target_expr,
false,
idx_desc,
)
} else if let Some((idx_schema, target_expr)) = eq_residual {
// --- Eq-prefix + residual seek (bd-nonagg-eq-residual) ---
// Reached when the IndexEquality directive bypassed on the residual (or none was emitted) and
// `index_eq_target`'s bare-`Eq` detection missed the conjunction. Seek the `col = lit` block and filter
// the FULL WHERE per row (`residual_filter = true`) instead of scanning every row.
codegen_select_index_equality_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
idx_schema,
target_expr,
true,
false,
)
} else if has_aggregate_columns(columns) && !group_by.is_empty() {
// --- Aggregate query WITH GROUP BY ---
codegen_select_group_by_aggregate(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
group_by,
having.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
)
} else if has_aggregate_columns(columns) {
// --- Aggregate query (single-group, no GROUP BY) ---
let limit_anon_placeholder_base = stmt.limit.as_ref().map(|limit_clause| {
let limit_placeholder_count = count_anon_placeholders(&limit_clause.limit)
+ limit_clause
.offset
.as_ref()
.map_or(0, count_anon_placeholders);
b.current_anon_placeholder()
+ count_anon_placeholders_in_select(stmt).saturating_sub(limit_placeholder_count)
});
codegen_select_aggregate(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
having.as_deref(),
stmt.limit.as_ref(),
limit_anon_placeholder_base,
out_regs,
out_col_count,
done_label,
end_label,
aggregate_index_eq_seek_allowed(from_index_hint),
from_index_hint,
)
} else if !stmt.order_by.is_empty() {
if let Some(index_plan) = ctx
.index_ordered_scan_reliable
.then(|| {
resolve_order_by_index_plan(
table,
table_alias,
columns,
where_clause.as_deref(),
&stmt.order_by,
distinct,
)
})
.flatten()
{
tracing::info!(
table = %table.name,
index = %index_plan.index.name,
covering = index_plan.covering_output.is_some(),
descending = index_plan.descending,
"vdbe.order_by.index_bypass"
);
return codegen_select_index_ordered_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
&index_plan,
);
}
// bd-nonagg-rowid-order-scan (+ -desc): `ORDER BY <rowid>` needs NO sorter — a forward table
// walk (Rewind+Next) is ascending rowid order and a reverse walk (Last+Prev) is descending, and
// the rowid is unique so there are no ties to resolve. `resolve_order_by_index_plan` returns None
// for the rowid (no secondary index covers it), so we would otherwise sort every row. Route to
// the plain scan, which applies LIMIT/OFFSET and stops early under a LIMIT. `rowid_range_allowed`
// is exactly the safe gate (non-aggregate, no GROUP BY/HAVING/DISTINCT, and — since rowid_order
// is Some — the ORDER BY is a single rowid term). A WHERE is allowed only when
// `where_is_plain_scan_safe` proves it carries no MATCH and no subquery — `codegen_select_full_scan`
// applies it via the SAME per-row filter the sorter path uses, so scan and sort are byte-identical
// (the sort is just elided). MATCH / subquery cases keep the sorter (they churned golden snapshots
// and are not oracle-proven equivalent here).
// Both directions use the SAME scan emitter: `codegen_select_full_scan` walks ascending
// (`Rewind`+`Next`) or descending (`Last`+`Prev`) and applies the WHERE + LIMIT/OFFSET the same
// way the sorter path would, so it is byte-identical output with the sort elided.
if let Some(dir) = rowid_order.filter(|_| {
rowid_range_allowed && where_clause.as_deref().is_none_or(where_is_plain_scan_safe)
}) {
return codegen_select_full_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
dir == SortDirection::Desc,
);
}
// --- Full table scan with ORDER BY (sorter path) ---
codegen_select_ordered_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
&stmt.order_by,
stmt.limit.as_ref(),
distinct,
out_regs,
out_col_count,
done_label,
end_label,
)
} else if distinct == Distinctness::Distinct {
// bd-distinct-loose-scan: `SELECT DISTINCT <indexed col>` (no WHERE/GROUP BY/HAVING/LIMIT) is a
// loose/skip index scan — emit each distinct value once and `SeekGT` past its whole run — instead
// of scanning every row into a dedup sorter. Work scales with #distinct values, not rows.
if where_clause.is_none()
&& stmt.limit.is_none()
&& group_by.is_empty()
&& having.is_none()
&& let Some(scan) = distinct_loose_scan_plan(columns, table, table_alias)
{
return codegen_select_distinct_loose_scan(
b,
cursor,
&scan,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// --- Full table scan with DISTINCT ---
codegen_select_distinct_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
)
} else if let Some(fsqlite_ast::IndexHint::IndexedBy(hinted)) = from_index_hint
&& stmt.order_by.is_empty()
&& let Some(forced_idx) = table
.indexes
.iter()
.find(|idx| idx.name.eq_ignore_ascii_case(hinted))
&& forced_idx.supports_direct_column_lookup()
{
// bd-gh-indexed-by-desc-scan-order (#224): `INDEXED BY <idx>` is a HARD
// contract. When no WHERE constraint made the index seekable we reached
// the table-scan fallback and silently ignored the forced index. Instead
// scan the forced index as a FULL ordered index scan in its STORED order
// — a forward Rewind/Next walk, which yields ascending values for an ASC
// index and descending for a DESC index, matching C SQLite. Covering when
// every output column is in the index; otherwise the shared emitter reads
// the rowid and looks the row up in the table. `descending: false` keeps
// the forward (stored-order) traversal for both ASC and DESC indexes.
let plan = OrderByIndexPlan {
index: forced_idx.clone(),
descending: false,
equality_prefix_len: 0,
covering_output: resolve_covering_output_sources(
columns,
table,
table_alias,
forced_idx,
),
};
codegen_select_index_ordered_scan(
b,
cursor,
table,
table_alias,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
&plan,
)
} else {
// --- Full table scan ---
codegen_select_full_scan(
b,
cursor,
table,
table_alias,
time_travel,
schema,
columns,
where_clause.as_deref(),
stmt.limit.as_ref(),
out_regs,
out_col_count,
done_label,
end_label,
// reverse_unordered_selects: reverse only when the row order is not
// fixed by an ORDER BY (GH #236).
ctx.reverse_unordered_selects && stmt.order_by.is_empty(),
)
}
}
#[allow(clippy::too_many_arguments)]
fn codegen_select_rowid_lookup(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
time_travel: Option<&TimeTravelClause>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
limit_clause: Option<&LimitClause>,
target_expr: &Expr,
where_clause: Option<&Expr>,
// When true, the full `where_clause` is applied as a residual filter after `SeekRowid` — for
// `rowid = <const> AND <residual>`. The single lookup visits only the target row; the residual
// decides whether to emit it. The table is always open here, so the residual reads any column.
// `false` for the exact-`rowid = const` callers → byte-identical. bd-nonagg-rowid-eq-residual.
residual_filter: bool,
) -> Result<(), CodegenError> {
// Captured before `emit_expr(target_expr)`, which may consume an anon placeholder (`rowid = ?`).
// The residual re-applies the whole WHERE, so it must re-number from this base (else the `id = ?`
// conjunct would double-consume). Unused when `residual_filter` is false.
let where_placeholder_base = b.current_anon_placeholder();
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let rowid_reg = b.alloc_reg();
emit_expr(b, target_expr, rowid_reg, None);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
emit_set_snapshot(b, cursor, time_travel);
// Coerce a non-integer-literal rowid key (placeholder / real / text) to INTEGER affinity before the
// seek: a non-exact key (2.5 / 'abc' / NULL) rejects to `done_label` (empty result) instead of raw
// `SeekRowid` TRUNCATING it to a wrong rowid (e.g. `WHERE id = 2.5` must not match rowid 2). Emitted
// after `OpenRead` so `done_label`'s Close sees an open cursor, mirroring `SeekRowid`'s own miss jump.
// An integer literal is already exact -> no MustBeInt (byte-identical to the pre-existing callers).
// Same coercion the count_star / aggregate rowid-eq paths use (bd-count/agg-rowid-eq-coerced).
if !matches!(target_expr, Expr::Literal(Literal::Integer(_), _)) {
b.emit_jump_to_label(Opcode::MustBeInt, rowid_reg, 0, done_label, P4::None, 0);
}
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
done_label,
P4::None,
0,
);
// bd-nonagg-rowid-eq-residual: narrow the single seeked row with the full WHERE. A residual miss
// jumps to `done_label` (Close + Halt) — there is only ever one row on this path.
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
done_label,
);
}
let skip_label = b.emit_label();
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn codegen_select_index_equality_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
target_expr: &Expr,
// When true, the FAST duplicate-run path also applies the full `where_clause` as a per-row residual
// filter (the fast path otherwise emits every eq-block row unfiltered, correct only when the WHERE is
// exactly the eq prefix). Callers whose WHERE is just `col = lit` pass `false` — their codegen is then
// byte-identical. Only the bd-nonagg-eq-residual caller, whose WHERE is `col = lit AND <residual>`,
// passes `true`. bd-nonagg-eq-residual.
residual_filter: bool,
// When true, walk the eq value's rows in DESCENDING rowid order — seek to `(val, i64::MAX)` with
// `SeekLE` and step with `Prev` (and the affinity full-scan fallback uses `Last`+`Prev`) — to serve
// `ORDER BY <rowid> DESC`. The single-column ASC path's key-equality (`Ne`) check is
// direction-agnostic, so the value block is detected the same way. `false` is byte-identical to the
// ascending emission. Gated by the caller to a single-column ASC index. bd-nonagg-index-eq-order-rowid.
descending: bool,
) -> Result<(), CodegenError> {
let idx_cursor = 1_i32;
let full_scan_fallback = b.emit_label();
let duplicate_run_done = b.emit_label();
let where_placeholder_base = b.current_anon_placeholder();
// WITHOUT ROWID index entries carry a PK suffix instead of a trailing
// rowid (bd-rjaff): covering resolution is rowid-table shaped, so route
// WITHOUT ROWID through the table-lookup path below.
let wr_pk_indices = if table.without_rowid {
Some(without_rowid_pk_indices(table)?)
} else {
None
};
let covering_output = if wr_pk_indices.is_some() || residual_filter {
None
} else {
resolve_covering_output_sources(columns, table, table_alias, idx_schema)
};
let needs_table_lookup = covering_output.is_none();
let fast_path_done_label = if needs_table_lookup {
done_label
} else {
b.emit_label()
};
// Exact seek (single-column ASC INTEGER-affinity index probed by an integer literal): the index's
// storage-class order matches the WHERE comparison's, so a 0-match seek is authoritative and the
// full-scan fallback (an affinity safety net) is unnecessary — an absent key then produces the empty
// result directly, making `WHERE col=<absent> LIMIT 1` / EXISTS O(log n) not O(n). Non-exact indexes
// keep the fallback. bd-eq-seek-fallback-zero-match.
let exact_seek = idx_schema.key_term_count() == 1
&& !idx_schema.key_term_descending(0)
&& matches!(target_expr, Expr::Literal(Literal::Integer(_), _))
&& idx_schema
.columns
.first()
.and_then(|name| table.column_index(name))
.and_then(|i| table.columns.get(i))
.is_some_and(|c| c.affinity == 'D');
let seek_miss_label = if exact_seek {
fast_path_done_label
} else {
full_scan_fallback
};
let (limit_reg, offset_reg) =
emit_limit_offset_registers(b, limit_clause, fast_path_done_label);
// A composite index must be positioned with a true one-field prefix
// record. Padding unconstrained trailing terms with NULL is not a block
// floor when any such term is DESC: SeekGE would start in the trailing-NULL
// region and silently skip preceding non-NULL entries. Single-key indexes
// retain the exact `(key, rowid-floor)` record needed for rowid-order walks.
let composite_prefix_probe = idx_schema.key_term_count() > 1;
let probe_field_count = if composite_prefix_probe { 1 } else { 2 };
let probe_key_regs = b.alloc_regs(probe_field_count);
emit_expr(b, target_expr, probe_key_regs, None);
b.emit_jump_to_label(
Opcode::IsNull,
probe_key_regs,
0,
fast_path_done_label,
P4::None,
0,
);
let saw_index_match_reg = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, saw_index_match_reg, 0, P4::None, 0);
if !composite_prefix_probe {
let rowid_floor_reg = probe_key_regs + 1;
// Ascending seeks to `(val, i64::MIN)` (lowest rowid of the value)
// then walks up; descending seeks to `(val, i64::MAX)` (highest
// rowid) then walks down with `Prev`.
b.emit_op(
Opcode::Int64,
0,
rowid_floor_reg,
0,
P4::Int64(if descending { i64::MAX } else { i64::MIN }),
0,
);
}
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
probe_field_count,
probe_record_reg,
P4::None,
0,
);
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
if descending {
Opcode::SeekLE
} else {
Opcode::SeekGE
},
idx_cursor,
probe_record_reg,
seek_miss_label,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
if idx_schema.key_term_count() == 1 && !idx_schema.key_term_descending(0) {
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs,
idx_key_reg,
duplicate_run_done,
direct_lookup_index_comparison_p4(idx_schema),
0x10,
);
} else {
b.emit_jump_to_label(
Opcode::IdxGT,
idx_cursor,
probe_record_reg,
duplicate_run_done,
P4::None,
1,
);
}
let idx_skip_label = b.emit_label();
b.emit_op(Opcode::Integer, 1, saw_index_match_reg, 0, P4::None, 0);
let covering_rowid_reg = if let Some(pk_indices) = wr_pk_indices.as_ref() {
// WITHOUT ROWID: read the PK suffix stored after the index key terms
// and position the table b-tree on it (prefix probe; fall-through
// leaves the cursor on the matching row).
emit_without_rowid_index_to_table_seek(
b,
table,
cursor,
idx_cursor,
idx_schema,
pk_indices,
idx_skip_label,
);
None
} else {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
idx_skip_label,
P4::None,
0,
);
}
Some(rowid_reg)
};
// bd-nonagg-eq-residual: apply the full WHERE as a per-row residual filter on the fast seek path
// when the caller has a residual beyond the eq prefix. The seek already pins `col = lit` (so that
// conjunct is redundant here) and the row is positioned (table cursor for the non-covering caller
// this flag is gated to), so the residual (e.g. `c = 7`) narrows the eq block to the exact matches.
// The placeholder base is reset exactly as the fallback below does, so a `?` in the residual numbers
// identically on both runtime paths.
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
idx_skip_label,
);
}
// OFFSET applies to rows that satisfy WHERE, and projection expressions must
// not run for filtered-out candidates. Keep both after the residual filter.
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, idx_skip_label, P4::None, 0);
}
if let Some(covering_output) = covering_output.as_ref() {
let rowid_reg = covering_rowid_reg.ok_or_else(|| {
CodegenError::Unsupported(
"covering output requires a rowid-table index cursor".to_owned(),
)
})?;
emit_covering_output_reads(b, idx_cursor, rowid_reg, covering_output, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(
Opcode::DecrJumpZero,
lim_r,
0,
fast_path_done_label,
P4::None,
0,
);
}
b.resolve_label(idx_skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(
if descending {
Opcode::Prev
} else {
Opcode::Next
},
idx_cursor,
idx_loop_body,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, fast_path_done_label, P4::None, 0);
b.resolve_label(duplicate_run_done);
b.emit_jump_to_label(
Opcode::If,
saw_index_match_reg,
0,
fast_path_done_label,
P4::None,
0,
);
// Exact seek: 0 matches is authoritative → the empty result, not the O(n) fallback scan.
b.emit_jump_to_label(Opcode::Goto, 0, 0, seek_miss_label, P4::None, 0);
b.resolve_label(full_scan_fallback);
if !needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
let loop_start = b.current_addr();
// Affinity safety-net scan: mirror the seek's direction so `ORDER BY <rowid> DESC` stays descending
// even on this fallback path (it is only reached for non-exact seeks; exact integer seeks resolve a
// 0-match authoritatively without it).
b.emit_jump_to_label(
if descending {
Opcode::Last
} else {
Opcode::Rewind
},
cursor,
0,
done_label,
P4::None,
0,
);
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (loop_start + 1) as i32;
b.emit_op(
if descending {
Opcode::Prev
} else {
Opcode::Next
},
cursor,
loop_body,
0,
P4::None,
0,
);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
if !needs_table_lookup {
b.resolve_label(fast_path_done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
}
b.resolve_label(end_label);
Ok(())
}
/// Codegen for `SELECT <cols> FROM t WHERE <int col> IN (<int literals>)`.
///
/// bd-2dgf5. Seeks the index once per distinct value (ascending, matching C SQLite's
/// value-then-rowid output order), does the table lookup, and emits `ResultRow`. Non-covering
/// (always looks the row up). No scan fallback: the INTEGER-affinity + integer-literal +
/// de-duplicated gate in [`index_integer_in_list_target`] makes each probe exact and the runs
/// disjoint. The caller gates out ORDER BY / LIMIT / DISTINCT / WITHOUT ROWID.
#[allow(clippy::too_many_arguments)]
fn codegen_select_index_in_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
values: &[i64],
where_clause: Option<&Expr>,
// When true, the full `where_clause` is applied as a per-row residual filter after positioning on
// each IN-list run — for `col IN (ints) AND <residual>`. The seek visits a SUPERSET (the IN runs)
// and the residual narrows to the exact matches; the table is always open here (SeekRowid below), so
// the residual can read any column. `false` for the exact-IN caller → byte-identical. Placeholder
// numbering is reset before each run's filter so a `?` in the residual numbers the same on every
// run. bd-nonagg-in-list-residual.
residual_filter: bool,
) -> Result<(), CodegenError> {
let idx_cursor = 1_i32;
// Captured before any placeholder-emitting op (IN values are integer literals) so each residual
// filter can reset to it and number a `?` in the residual identically across runs.
let where_placeholder_base = b.current_anon_placeholder();
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
for &value in values {
let probe_key_regs = b.alloc_regs(2);
b.emit_op(Opcode::Int64, 0, probe_key_regs, 0, P4::Int64(value), 0);
b.emit_op(
Opcode::Int64,
0,
probe_key_regs + 1,
0,
P4::Int64(i64::MIN),
0,
);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
2,
probe_record_reg,
P4::None,
0,
);
let next_value = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
next_value,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let run_top = b.current_addr() as i32;
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs,
idx_key_reg,
next_value,
direct_lookup_index_comparison_p4(idx_schema),
0x10,
);
let skip_row = b.emit_label();
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, skip_row, P4::None, 0);
// bd-nonagg-in-list-residual: narrow the IN run to the exact matches with the full WHERE.
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(b, where_expr, cursor, table, table_alias, schema, skip_row);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(skip_row);
b.emit_op(Opcode::Next, idx_cursor, run_top, 0, P4::None, 0);
b.resolve_label(next_value);
}
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Codegen for `SELECT <cols> FROM t WHERE <indexed col> IN (SELECT ...)`
/// (uncorrelated) — bd-g5ys1.
///
/// Build phase: scan the subquery source once and materialize its non-NULL
/// values into a deduplicated ephemeral index (`Found`-guarded `IdxInsert`;
/// `IN` never matches NULL, and without the dedup guard a duplicate value in
/// the subquery would emit each matching outer row twice). Drive phase: for
/// each distinct value, `SeekGE` the outer index with a `(key, i64::MIN)`
/// probe and walk the equal-key run, doing the table lookup and emitting
/// `ResultRow` per matching row — the same value-then-rowid output order and
/// run-walk contract as [`codegen_select_index_in_scan`], and the same
/// build/drive shape as [`codegen_select_count_star_indexed_in_scan`]'s
/// materialized arm with the count step replaced by row output. Non-covering
/// (always looks the row up). The caller gates out ORDER BY / LIMIT /
/// DISTINCT / GROUP BY / HAVING / WITHOUT ROWID; the extractor gates out
/// `NOT IN`, correlated subqueries, index hints that exclude the index, and
/// collation mismatches.
#[allow(clippy::too_many_arguments)]
fn codegen_select_index_in_subquery_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
probe_source: &InProbeSource<'_>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let probe_cursor = cursor + 2;
let source_cursor = cursor + 3;
let key_collation = idx_schema
.key_term_collation(0)
.filter(|name| !name.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |name| P4::Collation(name.to_owned()));
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_op(
Opcode::OpenAutoindex,
probe_cursor,
1,
0,
key_collation.clone(),
0,
);
b.emit_op(
Opcode::OpenRead,
source_cursor,
probe_source.table.root_page,
0,
P4::Table(probe_source.table.name.clone()),
0,
);
let r_value = b.alloc_temp();
let r_key = b.alloc_temp();
let build_done = b.emit_label();
let build_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, source_cursor, 0, build_done, P4::None, 0);
let skip_source_row = probe_source.where_clause.map(|_| b.emit_label());
if let (Some(where_expr), Some(skip_label)) = (probe_source.where_clause, skip_source_row) {
emit_where_filter(
b,
where_expr,
source_cursor,
probe_source.table,
probe_source.table_alias,
schema,
skip_label,
);
}
let probe_scan = ScanCtx {
cursor: source_cursor,
table: probe_source.table,
table_alias: probe_source.table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_in_probe_value(b, source_cursor, probe_source, r_value, &probe_scan);
let skip_insert = b.emit_label();
let next_source = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_value, 0, next_source, P4::None, 0);
b.emit_op(Opcode::MakeRecord, r_value, 1, r_key, P4::None, 0);
b.emit_jump_to_label(Opcode::Found, probe_cursor, r_key, skip_insert, P4::None, 0);
b.emit_op(Opcode::IdxInsert, probe_cursor, r_key, 0, P4::None, 0);
b.resolve_label(skip_insert);
b.resolve_label(next_source);
if let Some(skip_label) = skip_source_row {
b.resolve_label(skip_label);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let build_loop_body = (build_start + 1) as i32;
b.emit_op(Opcode::Next, source_cursor, build_loop_body, 0, P4::None, 0);
b.resolve_label(build_done);
b.emit_op(Opcode::Close, source_cursor, 0, 0, P4::None, 0);
b.free_temp(r_key);
b.free_temp(r_value);
// `MakeRecord r_probe_value, 2` reads two consecutive registers, so
// `r_min_rowid` must be allocated immediately after `r_probe_value`.
let r_probe_value = b.alloc_reg();
let r_min_rowid = b.alloc_reg();
let r_probe_record = b.alloc_reg();
let r_current_key = b.alloc_reg();
let rowid_reg = b.alloc_reg();
b.emit_jump_to_label(Opcode::Rewind, probe_cursor, 0, done_label, P4::None, 0);
let probe_loop_top = b.current_addr();
b.emit_op(Opcode::Column, probe_cursor, 0, r_probe_value, P4::None, 0);
b.emit_op(Opcode::Int64, 0, r_min_rowid, 0, P4::Int64(i64::MIN), 0);
b.emit_op(
Opcode::MakeRecord,
r_probe_value,
2,
r_probe_record,
P4::None,
0,
);
let next_probe = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
r_probe_record,
next_probe,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let run_top = b.current_addr() as i32;
b.emit_op(Opcode::Column, idx_cursor, 0, r_current_key, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
r_probe_value,
r_current_key,
next_probe,
key_collation,
0,
);
let skip_output = b.emit_label();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_output,
P4::None,
0,
);
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(skip_output);
b.emit_op(Opcode::Next, idx_cursor, run_top, 0, P4::None, 0);
b.resolve_label(next_probe);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let probe_loop_body = probe_loop_top as i32;
b.emit_op(Opcode::Next, probe_cursor, probe_loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, probe_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Codegen for `SELECT <cols> FROM t WHERE <rowid> IN (<int literals>)`.
///
/// bd-2dgf5. Rowid is unique, so each distinct value is a single `SeekRowid` — no index
/// cursor, no duplicate-run loop. Values are ascending (matching C SQLite's IN order), and a
/// `SeekRowid` miss skips to the next value. Integer-literal gate makes each seek exact. The
/// caller gates out LIMIT / DISTINCT / WITHOUT ROWID. bd-nonagg-rowid-in-order: `ORDER BY <rowid>`
/// is served by this seek (ascending is the natural order; `descending` reverses the emit order).
#[allow(clippy::too_many_arguments)]
fn codegen_select_rowid_in_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
values: &[i64],
where_clause: Option<&Expr>,
// When true, the full `where_clause` is applied as a per-row residual filter after `SeekRowid` — for
// `rowid IN (ints) AND <residual>`. The rowid lookups visit only the listed rows; the residual
// narrows them. The table is always open here, so the residual reads any column. `false` for the
// exact-`rowid IN` caller → byte-identical. Placeholder base reset per lookup so a `?` in the
// residual numbers identically. bd-nonagg-rowid-in-residual.
residual_filter: bool,
// When true, emit the seeks in DESCENDING rowid order (for `ORDER BY <rowid> DESC`); `values` are
// ascending, so we reverse. `false` emits ascending — the natural order and byte-identical to the
// no-ORDER-BY callers. bd-nonagg-rowid-in-order.
descending: bool,
) -> Result<(), CodegenError> {
// Captured before any placeholder-emitting op (rowid values are integer literals).
let where_placeholder_base = b.current_anon_placeholder();
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// `values` are ascending; reverse for `ORDER BY <rowid> DESC`. Ascending path clones in-order, so it
// stays byte-identical to the pre-order callers.
let mut ordered = values.to_vec();
if descending {
ordered.reverse();
}
for &value in &ordered {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::Int64, 0, rowid_reg, 0, P4::Int64(value), 0);
let next_value = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
next_value,
P4::None,
0,
);
// bd-nonagg-rowid-in-residual: narrow the listed rows with the full WHERE.
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
next_value,
);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(next_value);
}
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Codegen for a full table scan SELECT with optional WHERE filtering and LIMIT/OFFSET.
#[allow(clippy::too_many_arguments)]
fn codegen_select_full_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
time_travel: Option<&TimeTravelClause>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
// When true, walk the table b-tree in DESCENDING rowid order (`Last` + `Prev`) instead of ascending
// (`Rewind` + `Next`) — used to serve `ORDER BY <rowid> DESC` without a sorter. Everything else
// (WHERE filter, LIMIT/OFFSET, placeholder numbering) is identical, so `false` is byte-identical to
// the pre-flag behavior. bd-nonagg-rowid-order-scan-desc.
descending: bool,
) -> Result<(), CodegenError> {
// Allocate LIMIT/OFFSET counter registers (if present).
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
emit_set_snapshot(b, cursor, time_travel);
// Position at the first row in scan order (`Rewind` = ascending rowid, `Last` = descending); jump to
// done if the table is empty.
let loop_start = b.current_addr();
b.emit_jump_to_label(
if descending {
Opcode::Last
} else {
Opcode::Rewind
},
cursor,
0,
done_label,
P4::None,
0,
);
// Evaluate WHERE condition (if any) and skip non-matching rows.
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
// OFFSET: if offset counter > 0, decrement by 1 and skip this row.
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
// Read columns.
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
// ResultRow.
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
// LIMIT: decrement limit counter; jump to done when zero.
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
// Skip label for WHERE-filtered rows.
b.resolve_label(skip_label);
// Advance to the next row in scan order (`Next` ascending, `Prev` descending); jump back to the
// start of the loop body (the instruction after Rewind/Last).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (loop_start + 1) as i32;
b.emit_op(
if descending {
Opcode::Prev
} else {
Opcode::Next
},
cursor,
loop_body,
0,
P4::None,
0,
);
// Done: Close + Halt.
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
Ok(())
}
#[derive(Clone, Copy)]
struct RowidRangeBound<'a> {
rowid_expr: &'a Expr,
expr: &'a Expr,
inclusive: bool,
}
#[derive(Clone, Copy, Default)]
struct RowidRangeTarget<'a> {
lower: Option<RowidRangeBound<'a>>,
upper: Option<RowidRangeBound<'a>>,
}
#[derive(Clone)]
enum ColumnRangeExpr<'a> {
Borrowed(&'a Expr),
Owned(Box<Expr>),
}
#[derive(Clone)]
struct ColumnRangeBound<'a> {
expr: ColumnRangeExpr<'a>,
inclusive: bool,
}
impl ColumnRangeBound<'_> {
fn expr(&self) -> &Expr {
match &self.expr {
ColumnRangeExpr::Borrowed(expr) => expr,
ColumnRangeExpr::Owned(expr) => expr,
}
}
}
#[derive(Clone, Default)]
struct ColumnRangeTarget<'a> {
lower: Option<ColumnRangeBound<'a>>,
upper: Option<ColumnRangeBound<'a>>,
}
fn planner_index_range_target_from_column_range(
range: &ColumnRangeTarget<'_>,
) -> PlannerIndexRangeTarget {
PlannerIndexRangeTarget {
lower: range.lower.as_ref().map(|bound| PlannerIndexRangeBound {
expr: bound.expr().clone(),
inclusive: bound.inclusive,
}),
upper: range.upper.as_ref().map(|bound| PlannerIndexRangeBound {
expr: bound.expr().clone(),
inclusive: bound.inclusive,
}),
}
}
/// Generate VDBE bytecode for a bounded rowid/IPK scan.
///
/// This specializes the common `rowid >= low AND rowid < high` shape into a
/// SeekGE/SeekGT + Next loop so range scans do not fall back to a full table
/// scan. The optimization is intentionally conservative: it only fires when
/// the entire WHERE clause is a pure conjunction of rowid bounds.
#[allow(clippy::too_many_arguments)]
fn codegen_select_rowid_range_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
time_travel: Option<&TimeTravelClause>,
schema: &[TableSchema],
columns: &[ResultColumn],
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
rowid_range: RowidRangeTarget<'_>,
descending: bool,
where_clause: Option<&Expr>,
// When true, the full `where_clause` is applied as a per-row residual filter inside the walk — for
// `rowid <range> AND <residual>`. The walk visits the `[lower, upper]` slice (a superset of the
// matches) in rowid order; the residual narrows it. Byte-identical to the full scan (same rows, same
// order, fewer visited). `false` for the plain-range callers → byte-identical. The residual hoist
// requires integer-literal bounds and no LIMIT, so nothing before the filter emits a placeholder.
// bd-nonagg-rowid-range-residual.
residual_filter: bool,
) -> Result<(), CodegenError> {
// Captured before any placeholder-emitting op so the re-applied WHERE re-numbers a residual `?` from
// this base. Unused when `residual_filter` is false.
let where_placeholder_base = b.current_anon_placeholder();
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let lower_reg = rowid_range.lower.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
reg
});
let upper_reg = rowid_range.upper.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
reg
});
let lower_comparison = rowid_range
.lower
.map(|bound| resolved_rowid_range_comparison(table, table_alias, schema, bound));
let upper_comparison = rowid_range
.upper
.map(|bound| resolved_rowid_range_comparison(table, table_alias, schema, bound));
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
emit_set_snapshot(b, cursor, time_travel);
if descending {
if let Some(bound) = rowid_range.upper {
let seek_opcode = if bound.inclusive {
Opcode::SeekLE
} else {
Opcode::SeekLT
};
b.emit_jump_to_label(
seek_opcode,
cursor,
upper_reg.expect("upper bound register should exist"),
done_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Last, cursor, 0, done_label, P4::None, 0);
}
} else if let Some(bound) = rowid_range.lower {
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(
seek_opcode,
cursor,
lower_reg.expect("lower bound register should exist"),
done_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, done_label, P4::None, 0);
}
let loop_top = b.current_addr();
let skip_label = b.emit_label();
if descending {
if let Some(bound) = rowid_range.lower {
let current_rowid_reg = b.alloc_reg();
let stop_opcode = if bound.inclusive {
Opcode::Lt
} else {
Opcode::Le
};
b.emit_op(Opcode::Rowid, cursor, current_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
stop_opcode,
lower_reg.expect("lower bound register should exist"),
current_rowid_reg,
done_label,
lower_comparison
.as_ref()
.map_or(P4::None, |comparison| comparison.collation_p4.clone()),
lower_comparison
.as_ref()
.map_or(0, |comparison| comparison.cmp_p5),
);
}
} else if let Some(bound) = rowid_range.upper {
let current_rowid_reg = b.alloc_reg();
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_op(Opcode::Rowid, cursor, current_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
stop_opcode,
upper_reg.expect("upper bound register should exist"),
current_rowid_reg,
done_label,
upper_comparison
.as_ref()
.map_or(P4::None, |comparison| comparison.collation_p4.clone()),
upper_comparison
.as_ref()
.map_or(0, |comparison| comparison.cmp_p5),
);
}
// bd-nonagg-rowid-range-residual: narrow the [lower, upper] slice with the full WHERE. A residual
// miss jumps to `skip_label` (the Next), so the walk continues to the next row. Applied before the
// OFFSET count so OFFSET skips only matching rows (the residual hoist gates on no-LIMIT, so
// offset_reg is None here anyway).
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(
if descending {
Opcode::Prev
} else {
Opcode::Next
},
cursor,
loop_body,
0,
P4::None,
0,
);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn codegen_select_index_range_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
index_range: PlannerIndexRangeTarget,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
// bd-u6tbr / bd-xiojw follow-up: coerce a runtime-typed bound (a placeholder) to the indexed
// column's affinity so the seek positions identically to the full-scan filter, which applies
// the comparison affinity. A literal already in the column's affinity class is left
// byte-identical (no Affinity op) — the coercion only matters for placeholders. Numeric
// ('C'/'D'/'E') and BINARY-text ('B') columns coerce; an untyped column needs none, and an
// expression index has no plain key column so `column_index` misses and no coercion is emitted
// (behaviour preserved). For a 'B' column the gate only accepts the seek when the collation is
// BINARY (`P4::None`), so the coerced probe and the seek's BINARY comparison agree.
let bound_affinity = idx_schema
.columns
.first()
.and_then(|name| table.column_index(name))
.map(|idx| table.columns[idx].affinity)
.filter(|&aff| matches!(aff, 'C' | 'D' | 'E' | 'B'));
let lower_probe = index_range.lower.as_ref().map(|bound| {
let base = b.alloc_regs(2);
emit_expr(b, &bound.expr, base, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, &bound.expr)
{
b.emit_op(
Opcode::Affinity,
base,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, base, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Int64, 0, base + 1, 0, P4::Int64(i64::MIN), 0);
(base, bound.clone())
});
let upper_reg = index_range.upper.as_ref().map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, &bound.expr, reg, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, &bound.expr)
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
reg
});
let current_key_reg = (upper_reg.is_some()
|| lower_probe
.as_ref()
.is_some_and(|(_, bound)| !bound.inclusive))
.then(|| b.alloc_reg());
// WITHOUT ROWID index entries carry a PK suffix instead of a trailing
// rowid (bd-rjaff): covering resolution is rowid-table shaped, so route
// WITHOUT ROWID through the table-lookup path below.
let wr_pk_indices = if table.without_rowid {
Some(without_rowid_pk_indices(table)?)
} else {
None
};
let covering_output = if wr_pk_indices.is_some() {
None
} else {
resolve_covering_output_sources(columns, table, table_alias, idx_schema)
};
let needs_table_lookup = covering_output.is_none();
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
if let Some((lower_reg, _)) = lower_probe.as_ref() {
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
*lower_reg,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
done_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, done_label, P4::None, 0);
}
let loop_top = b.current_addr();
let skip_label = b.emit_label();
if let Some(key_reg) = current_key_reg {
b.emit_op(Opcode::Column, idx_cursor, 0, key_reg, P4::None, 0);
if lower_probe.is_none() {
b.emit_jump_to_label(Opcode::IsNull, key_reg, 0, skip_label, P4::None, 0);
}
}
if let Some((lower_reg, bound)) = lower_probe.as_ref()
&& !bound.inclusive
{
b.emit_jump_to_label(
Opcode::Le,
*lower_reg,
current_key_reg.expect("exclusive lower bound should read current key"),
skip_label,
P4::None,
0,
);
}
if let Some(bound) = index_range.upper.as_ref() {
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_jump_to_label(
stop_opcode,
upper_reg.expect("upper bound register should exist"),
current_key_reg.expect("upper bound should read current key"),
done_label,
P4::None,
0,
);
}
if let Some(pk_indices) = wr_pk_indices.as_ref() {
// WITHOUT ROWID: read the PK suffix stored after the index key terms
// and position the table b-tree on it (prefix probe; fall-through
// leaves the cursor on the matching row).
emit_without_rowid_index_to_table_seek(
b, table, cursor, idx_cursor, idx_schema, pk_indices, skip_label,
);
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
} else {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
if let Some(covering_output) = &covering_output {
emit_covering_output_reads(b, idx_cursor, rowid_reg, covering_output, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table_lookup {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Reverse (DESC) single-column index-range seek: `WHERE col <range> ORDER BY col DESC, id DESC`.
/// The mirror of [`codegen_select_index_range_scan`] — it positions at the HIGH end of the range
/// (`SeekLE(upper)` with a MAX rowid sentinel, or `Last` when there is no upper bound), walks down
/// with `Prev`, and stops at the LOWER bound (or at the NULL region at the index bottom when there
/// is no lower bound). Emits rows in `(col DESC, rowid DESC)` order — exactly what the reverse index
/// walk produces for `ORDER BY col DESC, id DESC`, so it is bit-identical without a sorter. Rowid
/// tables only (WITHOUT ROWID declined at detection).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_index_range_scan_desc(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
range: &ColumnRangeTarget<'_>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let bound_affinity = idx_schema
.columns
.first()
.and_then(|name| table.column_index(name))
.map(|idx| table.columns[idx].affinity)
.filter(|&aff| matches!(aff, 'C' | 'D' | 'E' | 'B'));
// Upper bound anchors the seek (position at the high end); rowid sentinel MAX so `SeekLE`
// lands on the LAST entry with `col == upper`.
let upper_probe = range.upper.as_ref().map(|bound| {
let base = b.alloc_regs(2);
emit_expr(b, bound.expr(), base, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, bound.expr())
{
b.emit_op(
Opcode::Affinity,
base,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, base, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Int64, 0, base + 1, 0, P4::Int64(i64::MAX), 0);
(base, bound.inclusive)
});
// Lower bound is the stop (walking down).
let lower_reg = range.lower.as_ref().map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr(), reg, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, bound.expr())
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
(reg, bound.inclusive)
});
let current_key_reg = b.alloc_reg();
let covering_output = if table.without_rowid {
None
} else {
resolve_covering_output_sources(columns, table, table_alias, idx_schema)
};
let needs_table_lookup = covering_output.is_none();
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
if let Some((upper_base, _)) = upper_probe.as_ref() {
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
*upper_base,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekLE,
idx_cursor,
probe_record_reg,
done_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Last, idx_cursor, 0, done_label, P4::None, 0);
}
let loop_top = b.current_addr();
let skip_label = b.emit_label();
b.emit_op(Opcode::Column, idx_cursor, 0, current_key_reg, P4::None, 0);
// NULLs sit at the bottom of the index and never satisfy a range predicate, but the reverse walk
// descends into them — a lower-bound stop like `current < lower` yields NULL (not true) on a NULL
// key, so it would NOT fire. Stop unconditionally once a NULL key is reached; every entry below
// it is also NULL.
b.emit_jump_to_label(Opcode::IsNull, current_key_reg, 0, done_label, P4::None, 0);
// Lower stop (walking down): `current < lower` (inclusive `>=`) or `current <= lower`
// (exclusive `>`) ends the scan.
if let Some((lreg, inclusive)) = lower_reg {
let stop = if inclusive { Opcode::Lt } else { Opcode::Le };
b.emit_jump_to_label(stop, lreg, current_key_reg, done_label, P4::None, 0);
}
// Exclusive upper: `SeekLE` may land on `col == upper`; skip those (only at the very top).
if let Some((upper_base, inclusive)) = upper_probe.as_ref()
&& !*inclusive
{
b.emit_jump_to_label(
Opcode::Ge,
*upper_base,
current_key_reg,
skip_label,
P4::None,
0,
);
}
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
if let Some(cov) = &covering_output {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Prev, idx_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table_lookup {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Composite-index equality-prefix + trailing-range seek (`WHERE a = 5 AND b > 10` on
/// `index(a, b)`). Mirrors [`codegen_select_index_range_scan`] but builds a multi-column probe
/// (prefix values + range lower) and bounds the walk to the matching prefix with `IdxGT`
/// (`P5` = prefix length), reading and range-checking the trailing key column at index position
/// `prefix_len`. Placeholder/text bounds are coerced to each key column's affinity like the
/// single-column path. Rowid tables only (WITHOUT ROWID is declined at detection).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_composite_index_prefix_range_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
prefix_exprs: &[&Expr],
range: &ColumnRangeTarget<'_>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let prefix_len = prefix_exprs.len();
let key_terms = idx_schema.key_term_count();
let range_pos = prefix_len;
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
// Coercing affinity ('C'/'D'/'E'/'B') of each key column, or None (untyped -> no coercion).
let key_affinities: Vec<Option<char>> = (0..key_terms)
.map(|pos| {
idx_schema
.columns
.get(pos)
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&a| matches!(a, 'C' | 'D' | 'E' | 'B'))
})
.collect();
// Probe record: [prefix..., range-lower-or-NULL, trailing NULLs, rowid = MIN].
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let probe = b.alloc_regs(key_terms as i32 + 1);
for (pos, expr) in prefix_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe + pos as i32;
emit_expr(b, expr, reg, None);
if let Some(aff) = key_affinities[pos]
&& !bound_matches_affinity(aff, expr)
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let range_reg = probe + range_pos as i32;
let lower_inclusive = if let Some(lower) = range.lower.as_ref() {
emit_expr(b, lower.expr(), range_reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, lower.expr())
{
b.emit_op(
Opcode::Affinity,
range_reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, range_reg, 0, done_label, P4::None, 0);
Some(lower.inclusive)
} else {
b.emit_op(Opcode::Null, 0, range_reg, 0, P4::None, 0);
None
};
for pos in (range_pos + 1)..key_terms {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe + pos as i32;
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let rowid_sentinel = probe + key_terms as i32;
b.emit_op(Opcode::Int64, 0, rowid_sentinel, 0, P4::Int64(i64::MIN), 0);
let probe_rec = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::MakeRecord,
probe,
key_terms as i32 + 1,
probe_rec,
P4::None,
0,
);
let upper = range.upper.as_ref().map(|u| {
let reg = b.alloc_reg();
emit_expr(b, u.expr(), reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, u.expr())
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
(reg, u.inclusive)
});
let covering_output = resolve_covering_output_sources(columns, table, table_alias, idx_schema);
let needs_table_lookup = covering_output.is_none();
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
let loop_top = b.current_addr();
let skip_label = b.emit_label();
// Stop once the equality prefix changes (`IdxGT` compares only the first `prefix_len` columns).
// With an EMPTY prefix (pure leading-term range, bd-bn45n) there is nothing to compare, so skip
// the IdxGT entirely and let the range bounds alone terminate the walk: the SeekGE anchors the
// lower end and the range-upper check below (`Gt`/`Ge` on `range_key_reg`) stops it — a
// zero-column IdxGT would be a degenerate no-op we do not want to rely on.
if prefix_len > 0 {
#[allow(clippy::cast_possible_truncation)]
b.emit_jump_to_label(
Opcode::IdxGT,
idx_cursor,
probe_rec,
done_label,
P4::None,
prefix_len as u16,
);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let range_key_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
idx_cursor,
range_pos as i32,
range_key_reg,
P4::None,
0,
);
if range.lower.is_none() {
b.emit_jump_to_label(Opcode::IsNull, range_key_reg, 0, skip_label, P4::None, 0);
}
if lower_inclusive == Some(false) {
b.emit_jump_to_label(
Opcode::Le,
range_reg,
range_key_reg,
skip_label,
P4::None,
0,
);
}
if let Some((up_reg, up_inclusive)) = upper {
let stop = if up_inclusive { Opcode::Gt } else { Opcode::Ge };
b.emit_jump_to_label(stop, up_reg, range_key_reg, done_label, P4::None, 0);
}
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
if let Some(cov) = &covering_output {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table_lookup {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Composite-index equality-prefix + trailing-range seek in DESCENDING order
/// (`WHERE a = 5 AND b <range> ORDER BY b DESC, id DESC` on `index(a, b)`). The reverse mirror of
/// [`codegen_select_composite_index_prefix_range_scan`], and the composite analogue of
/// [`codegen_select_index_range_scan_desc`].
///
/// It anchors at the HIGH end of the `a == 5` block and walks down with `Prev`:
/// * inclusive upper (`b <= up`): `SeekLE [prefix.., up]` — the last entry with `(prefix.., b) <=
/// (prefix.., up)`;
/// * exclusive upper (`b < up`): `SeekLT [prefix.., up]` — the last entry `< (prefix.., up)`;
/// * no upper bound: `SeekLE [prefix..]` (a partial key) — the last entry in the `prefix` block.
///
/// A single `IdxLE`/`IdxLT` on `[prefix.., lower]` (`P5 = prefix_len + 1`) at the loop top ends the
/// walk: it fires when the equality prefix drops below `prefix` (left the block downward), when the
/// trailing key crosses the lower bound (`IdxLE` for exclusive `>`, `IdxLT` for inclusive `>=`), or
/// when a NULL trailing key is reached (NULLs sit at the block bottom and never satisfy a range).
/// When there is no lower bound the probe holds NULL in the trailing slot and `IdxLE` stops exactly
/// at the NULL region / prefix change. Streams `(range_col DESC, rowid DESC)` with no sorter; the
/// range column is guaranteed to be the last key term by `composite_order_by_satisfied_desc`.
/// Rowid tables only (WITHOUT ROWID is declined at detection).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_composite_index_prefix_range_scan_desc(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
prefix_exprs: &[&Expr],
range: &ColumnRangeTarget<'_>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let prefix_len = prefix_exprs.len();
let key_terms = idx_schema.key_term_count();
let range_pos = prefix_len;
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
// Coercing affinity ('C'/'D'/'E'/'B') of each key column, or None (untyped -> no coercion).
let key_affinities: Vec<Option<char>> = (0..key_terms)
.map(|pos| {
idx_schema
.columns
.get(pos)
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&a| matches!(a, 'C' | 'D' | 'E' | 'B'))
})
.collect();
// Emit the equality-prefix values into `reg` (coercing affinity), jumping to `done` on NULL
// (an `= NULL` prefix matches nothing). Emitted once for the anchor and once for the stop probe.
let emit_prefix = |b: &mut ProgramBuilder, base: i32| {
for (pos, expr) in prefix_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = base + pos as i32;
emit_expr(b, expr, reg, None);
if let Some(aff) = key_affinities[pos]
&& !bound_matches_affinity(aff, expr)
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
}
};
// Anchor probe: [prefix.., upper] when there is an upper bound, else [prefix..] (partial key).
let has_upper = range.upper.is_some();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let anchor_fields = prefix_len as i32 + i32::from(has_upper);
let anchor_base = b.alloc_regs(anchor_fields);
emit_prefix(b, anchor_base);
let anchor_seek_op = if let Some(upper) = range.upper.as_ref() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = anchor_base + range_pos as i32;
emit_expr(b, upper.expr(), reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, upper.expr())
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
// Inclusive upper: last entry <= (prefix.., up). Exclusive: last entry < (prefix.., up).
if upper.inclusive {
Opcode::SeekLE
} else {
Opcode::SeekLT
}
} else {
// No upper bound: the partial key [prefix..] positions at the last entry of the block.
Opcode::SeekLE
};
let anchor_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
anchor_base,
anchor_fields,
anchor_rec,
P4::None,
0,
);
// Stop probe: [prefix.., lower-or-NULL] (prefix_len + 1 == key_terms fields).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let stop_base = b.alloc_regs(key_terms as i32);
emit_prefix(b, stop_base);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let stop_range_reg = stop_base + range_pos as i32;
let lower_inclusive = if let Some(lower) = range.lower.as_ref() {
emit_expr(b, lower.expr(), stop_range_reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, lower.expr())
{
b.emit_op(
Opcode::Affinity,
stop_range_reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, stop_range_reg, 0, done_label, P4::None, 0);
Some(lower.inclusive)
} else {
b.emit_op(Opcode::Null, 0, stop_range_reg, 0, P4::None, 0);
None
};
let stop_rec = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::MakeRecord,
stop_base,
key_terms as i32,
stop_rec,
P4::None,
0,
);
// Inclusive lower (`b >= lo`): stop when idx_key < [prefix.., lo] (IdxLT). Exclusive (`b > lo`)
// or no lower bound (NULL trailing slot): stop when idx_key <= [prefix.., lo/NULL] (IdxLE) —
// which for a real trailing key only fires below `lo`, and for the NULL slot only fires at the
// NULL region / prefix change.
let stop_op = if lower_inclusive == Some(true) {
Opcode::IdxLT
} else {
Opcode::IdxLE
};
let covering_output = resolve_covering_output_sources(columns, table, table_alias, idx_schema);
let needs_table_lookup = covering_output.is_none();
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
anchor_seek_op,
idx_cursor,
anchor_rec,
done_label,
P4::None,
0,
);
let loop_top = b.current_addr();
let skip_label = b.emit_label();
// Prefix change / lower bound / NULL trailing key: one op ends the reverse walk.
#[allow(clippy::cast_possible_truncation)]
b.emit_jump_to_label(
stop_op,
idx_cursor,
stop_rec,
done_label,
P4::None,
(prefix_len + 1) as u16,
);
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
if let Some(cov) = &covering_output {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Prev, idx_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table_lookup {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Whether `expr` is a compile-time numeric literal — an integer/real literal, with
/// an optional leading unary minus. NUMERIC affinity is the identity on these, which
/// is what lets [`index_range_bound_is_seek_safe`] accept them under NUMERIC
/// comparison affinity without changing which rows match.
fn is_numeric_literal_bound(expr: &Expr) -> bool {
match expr {
Expr::Literal(Literal::Integer(_) | Literal::Float(_), _) => true,
Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Negate,
expr,
..
} => matches!(
expr.as_ref(),
Expr::Literal(Literal::Integer(_) | Literal::Float(_), _)
),
_ => false,
}
}
/// Whether `expr` is a bind parameter (`?`, `?N`, `:name`, `@name`, `$name`). A placeholder's
/// runtime value is unknown at compile time, so the range seek coerces it to the indexed
/// column's affinity at execution ([`codegen_select_index_range_scan`]) rather than relying on
/// a compile-time-numeric bound.
fn is_placeholder_bound(expr: &Expr) -> bool {
matches!(
expr,
Expr::Placeholder(
fsqlite_ast::PlaceholderType::Numbered(_)
| fsqlite_ast::PlaceholderType::ColonNamed(_)
| fsqlite_ast::PlaceholderType::AtNamed(_)
| fsqlite_ast::PlaceholderType::DollarNamed(_),
_
)
)
}
/// Whether `expr` is a text string literal. A text literal compared against a BINARY-collated
/// text column needs no coercion — it is already text, and the seek's default `P4::None`
/// comparison is BINARY (the index's own order) — so the range seek is exact.
fn is_text_literal_bound(expr: &Expr) -> bool {
matches!(expr, Expr::Literal(Literal::String(_), _))
}
/// Whether `expr` is a literal already in the seek column's affinity class, so the seek's
/// runtime `Affinity` coercion would be a no-op and can be skipped (keeping the bytecode
/// byte-identical for literals). Numeric-affinity columns match a numeric literal; a TEXT column
/// matches a text literal. A placeholder is never a match — its runtime type is unknown, so it is
/// always coerced.
fn bound_matches_affinity(affinity: char, expr: &Expr) -> bool {
match affinity {
'C' | 'D' | 'E' => is_numeric_literal_bound(expr),
'B' => is_text_literal_bound(expr),
_ => false,
}
}
/// Whether the index-range seek can position on `bound_expr` without changing which
/// rows match relative to the equivalent full-scan filter.
///
/// The seek compares the raw bound value against the index's (already affinity-applied)
/// key entries, so it is exact only when the comparison affinity the full-scan filter
/// WOULD apply is a no-op on this bound. Two cases qualify. First, no comparison affinity
/// (`cmp_p5 & !0x80 == 0`) — e.g. an untyped/BLOB column, or two operands already in the
/// same affinity class — is always safe. Second, NUMERIC affinity (`'C'`) against a numeric
/// literal (integer/real, incl. a negated literal): coercing an already-numeric value with
/// NUMERIC affinity is the identity, so the seek visits exactly the filter's rows. That
/// second case is the common `WHERE <int/real col> BETWEEN 5 AND 55` shape that the plain
/// `cmp_p5 == 0` gate silently rejected into a full scan (see
/// `resolved_index_range_comparison_carries_affinity_for_integer_column`); it is the same
/// proven-safe subset the IN-list seek accepts by the integer argument.
///
/// A placeholder under NUMERIC affinity is also accepted: `codegen_select_index_range_scan`
/// emits an `Affinity` opcode that coerces the runtime bound to the indexed column's affinity
/// before seeking, so the positioned rows match the filter for every bound type (a numeric bind
/// is exact; a non-numeric text/blob bind coerces to a value that seeks empty, exactly as the
/// filter's numeric comparison excludes it). A TEXT/blob *literal* under NUMERIC affinity still
/// declines (kept narrow; those are rare and the correct scan is cheap enough).
///
/// A text literal OR a placeholder on a BINARY-collated text column (TEXT comparison affinity,
/// with the collation gated to None/BINARY above) is accepted. A text literal is already text and
/// text-vs-text under `P4::None` is the index's own BINARY order (no coercion); a placeholder is
/// coerced to TEXT by the seek's Affinity op, so any bind type positions to match the filter (a
/// numeric/text bind becomes its text form; a blob stays a blob and seeks past the text keys,
/// empty, exactly as the filter excludes it). Non-BINARY collations (NOCASE/RTRIM) and a
/// numeric/blob *literal* on a text column still decline.
fn index_range_bound_is_seek_safe(
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
column_name: &str,
bound_expr: &Expr,
) -> bool {
let comparison =
resolved_index_range_comparison(table, table_alias, schema, column_name, bound_expr);
if !matches!(comparison.collation_p4, P4::None) {
return false;
}
// `cmp_p5 & !0x80` is the comparison affinity (`combine_comparison_affinity`): `0` = no
// coercion (always seek-safe), `b'C'` = NUMERIC, `b'B'` = TEXT. Under NUMERIC a numeric
// literal is already numeric (identity) and a placeholder is coerced to the column affinity
// by the seek's Affinity op. Under TEXT (a text column, collation gated to BINARY above) a
// text literal is already text and the seek's `P4::None` compare IS BINARY, so it is exact;
// a non-text-literal under TEXT (numeric literal, placeholder) would need a TEXT coercion and
// is declined.
let affinity = comparison.cmp_p5 & !0x80;
if affinity == 0 {
true
} else if affinity == u16::from(b'C') {
is_numeric_literal_bound(bound_expr) || is_placeholder_bound(bound_expr)
} else if affinity == u16::from(b'B') {
is_text_literal_bound(bound_expr) || is_placeholder_bound(bound_expr)
} else {
false
}
}
fn index_range_fast_path_is_safe(
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
column_name: &str,
range: &ColumnRangeTarget<'_>,
) -> bool {
[range.lower.as_ref(), range.upper.as_ref()]
.into_iter()
.flatten()
.all(|bound| {
index_range_bound_is_seek_safe(table, table_alias, schema, column_name, bound.expr())
})
}
fn rowid_range_fast_path_is_safe(range: RowidRangeTarget<'_>) -> bool {
[range.lower, range.upper]
.into_iter()
.flatten()
.all(|bound| rowid_range_bound_is_seek_safe(bound.expr))
}
fn rowid_range_bound_is_seek_safe(expr: &Expr) -> bool {
match expr {
Expr::Literal(Literal::Integer(_), _)
| Expr::Placeholder(
fsqlite_ast::PlaceholderType::Numbered(_)
| fsqlite_ast::PlaceholderType::ColonNamed(_)
| fsqlite_ast::PlaceholderType::AtNamed(_)
| fsqlite_ast::PlaceholderType::DollarNamed(_),
_,
) => true,
Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Negate,
expr,
..
} => matches!(expr.as_ref(), Expr::Literal(Literal::Integer(_), _)),
_ => false,
}
}
fn is_simple_count_star(columns: &[ResultColumn]) -> bool {
matches!(
columns,
[ResultColumn::Expr {
expr:
Expr::FunctionCall {
name,
args: FunctionArgs::Star,
distinct: false,
order_by,
filter: None,
over: None,
..
},
..
}] if name.eq_ignore_ascii_case("count")
&& order_by.is_empty()
&& builtin_aggregate_semantics_available(name, 0)
)
}
struct CountStarPlusSumPlan {
count_out_idx: usize,
sum_out_idx: usize,
sum_col_idx: Option<usize>,
sum_is_rowid: bool,
}
struct GroupByRowidBucketSumPlan {
group_divisor: i64,
group_out_idx: usize,
sum_out_idx: usize,
sum_col_idx: usize,
}
fn rowid_bucket_divisor(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<i64> {
let Expr::BinaryOp {
left,
op: BinaryOp::Divide,
right,
..
} = expr
else {
return None;
};
if !matches!(
resolve_column_ref(left, table, table_alias),
Some(SortKeySource::Rowid)
) {
return None;
}
match right.as_ref() {
Expr::Literal(Literal::Integer(divisor), _) if *divisor > 0 => Some(*divisor),
_ => None,
}
}
fn simple_group_by_rowid_bucket_sum_plan(
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
group_by: &[Expr],
) -> Option<GroupByRowidBucketSumPlan> {
if columns.len() != 2 || group_by.len() != 1 || !builtin_aggregate_semantics_available("sum", 1)
{
return None;
}
let group_expr = &group_by[0];
let group_divisor = rowid_bucket_divisor(group_expr, table, table_alias)?;
let mut group_out_idx = None;
let mut sum_out_idx = None;
let mut sum_col_idx = None;
for (out_idx, column) in columns.iter().enumerate() {
match column {
ResultColumn::Expr { expr, .. } if expr == group_expr => {
if group_out_idx.replace(out_idx).is_some() {
return None;
}
}
ResultColumn::Expr {
expr:
Expr::FunctionCall {
name,
args,
distinct: false,
order_by,
filter: None,
over: None,
..
},
..
} if name.eq_ignore_ascii_case("sum") && order_by.is_empty() => {
let FunctionArgs::List(exprs) = args else {
return None;
};
let [arg_expr] = exprs.as_slice() else {
return None;
};
let Some(SortKeySource::Column(idx)) =
resolve_column_ref(arg_expr, table, table_alias)
else {
return None;
};
if sum_out_idx.replace(out_idx).is_some() {
return None;
}
sum_col_idx = Some(idx);
}
_ => return None,
}
}
Some(GroupByRowidBucketSumPlan {
group_divisor,
group_out_idx: group_out_idx?,
sum_out_idx: sum_out_idx?,
sum_col_idx: sum_col_idx?,
})
}
fn simple_count_star_plus_sum_plan(
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<CountStarPlusSumPlan> {
if columns.len() != 2
|| !builtin_aggregate_semantics_available("count", 0)
|| !builtin_aggregate_semantics_available("sum", 1)
{
return None;
}
let mut count_out_idx = None;
let mut sum_out_idx = None;
let mut sum_col_idx = None;
let mut sum_is_rowid = false;
for (out_idx, column) in columns.iter().enumerate() {
let ResultColumn::Expr { expr, .. } = column else {
return None;
};
let Expr::FunctionCall {
name,
args,
distinct: false,
order_by,
filter: None,
over: None,
..
} = expr
else {
return None;
};
if !order_by.is_empty() {
return None;
}
if name.eq_ignore_ascii_case("count") && matches!(args, FunctionArgs::Star) {
if count_out_idx.replace(out_idx).is_some() {
return None;
}
continue;
}
if name.eq_ignore_ascii_case("sum")
&& let FunctionArgs::List(exprs) = args
&& let [arg_expr] = exprs.as_slice()
{
match resolve_column_ref(arg_expr, table, table_alias) {
Some(SortKeySource::Column(idx)) => {
if sum_out_idx.replace(out_idx).is_some() {
return None;
}
sum_col_idx = Some(idx);
sum_is_rowid = false;
continue;
}
Some(SortKeySource::Rowid) => {
if sum_out_idx.replace(out_idx).is_some() {
return None;
}
sum_col_idx = None;
sum_is_rowid = true;
continue;
}
Some(SortKeySource::Expression(_)) | None => return None,
}
}
return None;
}
Some(CountStarPlusSumPlan {
count_out_idx: count_out_idx?,
sum_out_idx: sum_out_idx?,
sum_col_idx,
sum_is_rowid,
})
}
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_count_star(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
where_clause: Option<&Expr>,
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
rowid_range: Option<RowidRangeTarget<'_>>,
index_hint: Option<&fsqlite_ast::IndexHint>,
) -> Result<(), CodegenError> {
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
if let Some((idx_schema, probe_target)) =
extract_count_indexed_exists_target(where_clause, table, table_alias, schema)
{
return codegen_select_count_star_indexed_in_scan(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
idx_schema,
probe_target,
);
}
if let Some((idx_schema, in_target)) = extract_count_indexed_in_target(
where_clause,
table,
table_alias,
schema,
&scan_ctx,
index_hint,
) {
return codegen_select_count_star_indexed_in_scan(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
idx_schema,
in_target,
);
}
// bd-count-rowid-in: `COUNT(*) WHERE <rowid> IN (<int literals>)` counts existing rows with one
// SeekRowid per listed value instead of a full scan. Bare rowid IN only (`extract_rowid_in_list_target`
// requires the whole WHERE be the IN); a residual falls through to the scan below. The rowid slice is
// sorted+deduped, so each hit is counted at most once.
if !table.without_rowid
&& let Some(values) = extract_rowid_in_list_target(where_clause, table, table_alias)
{
codegen_select_count_star_rowid_in(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
&values,
None,
false,
);
return Ok(());
}
// bd-count-rowid-eq: `COUNT(*) WHERE <rowid> = <int literal>` counts the single row (0 or 1) with one
// SeekRowid instead of a full scan — the rowid (IPK) has no secondary index, so this shape is NOT
// diverted to the aggregate index-eq seek (bd-2dgf5) and would otherwise Rewind here. This first arm
// handles integer literals directly and reuses the rowid-IN emitter with a one-element slice. Other
// constant forms continue to the MustBeInt-coerced seek below so reals cannot be truncated and
// placeholders receive affinity handling.
if !table.without_rowid
&& let Some(target) = extract_rowid_target_expr(where_clause, Some(table), table_alias)
&& let Expr::Literal(Literal::Integer(value), _) = target
{
codegen_select_count_star_rowid_in(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
&[*value],
None,
false,
);
return Ok(());
}
// bd-count-rowid-eq-coerced: `COUNT(*) WHERE <rowid> = <non-integer-literal constant>` (a placeholder,
// real, or text constant) counts the single row via `MustBeInt` (INTEGER-affinity coerce: a non-exact
// integer — 2.5, 'abc', NULL — rejects to count 0, exactly as SQLite; '5' / 5.0 coerce to 5) then one
// SeekRowid, instead of a full scan. The integer-literal case is served above (no MustBeInt needed).
if !table.without_rowid
&& let Some(target) = extract_rowid_target_expr(where_clause, Some(table), table_alias)
{
codegen_select_count_star_rowid_eq_coerced(
b, cursor, table, out_regs, done_label, end_label, target,
);
return Ok(());
}
// bd-count-rowid-in-residual: `COUNT(*) WHERE <rowid> IN (<ints>) AND <residual>` — SeekRowid per value,
// re-applying the full WHERE per hit. Reached only when no `simple_count_star` diverter matched (a
// residual on an indexed column routes to the aggregate seek instead; the rowid IN itself has no index
// so `index_integer_in_list_residual_target` never diverts it).
if !table.without_rowid
&& let Some((values, true)) =
extract_rowid_in_list_residual_target(where_clause, table, table_alias)
{
codegen_select_count_star_rowid_in(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
&values,
where_clause,
true,
);
return Ok(());
}
// bd-count-rowid-eq-residual: `COUNT(*) WHERE <rowid> = <int> AND <residual>` — one SeekRowid + residual.
// `extract_rowid_eq_residual_target` returns the const only when `rowid = const` is a conjunct alongside
// others (declines the bare eq, handled above). Integer literal only.
if !table.without_rowid
&& let Some(target) = extract_rowid_eq_residual_target(where_clause, table, table_alias)
&& let Expr::Literal(Literal::Integer(value), _) = target
{
codegen_select_count_star_rowid_in(
b,
cursor,
table,
table_alias,
schema,
out_regs,
done_label,
end_label,
&[*value],
where_clause,
true,
);
return Ok(());
}
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
if where_clause.is_none() {
// bd-wwqen.1: cheapest-index optimization for COUNT(*).
// Open the smallest non-partial index instead of the table when
// available — index B-trees have smaller rows and fewer pages.
// Uses the SAME cursor ID as the table would, so the rest of the
// program shape (Count, Close, Halt) is identical.
let cheapest_index = table
.indexes
.iter()
.filter(|idx| idx.where_clause.is_none())
.filter(|idx| !idx.columns.is_empty())
.min_by_key(|idx| idx.columns.len());
if let Some(idx) = cheapest_index {
b.emit_op(
Opcode::OpenRead,
cursor,
idx.root_page,
0,
P4::Index(idx.name.clone()),
0,
);
} else {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(Opcode::Count, cursor, out_regs, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
return Ok(());
}
// Non-COUNT path: open table cursor as before.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(Opcode::Integer, 0, out_regs, 0, P4::None, 0);
let upper_reg = rowid_range.and_then(|range| {
range.upper.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
reg
})
});
let upper_comparison = rowid_range.and_then(|range| {
range
.upper
.map(|bound| resolved_rowid_range_comparison(table, table_alias, schema, bound))
});
if let Some(range) = rowid_range {
if let Some(bound) = range.lower {
let lower_reg = b.alloc_reg();
emit_expr(b, bound.expr, lower_reg, None);
b.emit_jump_to_label(Opcode::IsNull, lower_reg, 0, done_label, P4::None, 0);
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(seek_opcode, cursor, lower_reg, done_label, P4::None, 0);
} else {
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, done_label, P4::None, 0);
}
} else {
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, done_label, P4::None, 0);
}
let loop_top = b.current_addr();
let skip_label = b.emit_label();
if let Some(range) = rowid_range
&& let Some(bound) = range.upper
{
let current_rowid_reg = b.alloc_reg();
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_op(Opcode::Rowid, cursor, current_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
stop_opcode,
upper_reg.expect("upper bound register should exist"),
current_rowid_reg,
done_label,
upper_comparison
.as_ref()
.map_or(P4::None, |comparison| comparison.collation_p4.clone()),
upper_comparison
.as_ref()
.map_or(0, |comparison| comparison.cmp_p5),
);
}
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
b.emit_op(Opcode::AddImm, out_regs, 1, 0, P4::None, 0);
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, cursor, loop_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// `SELECT COUNT(*) FROM t WHERE <rowid> IN (<int literals>) [AND <residual>]` / `<rowid> = <int> [AND
/// <residual>]`: one `SeekRowid` per listed value, counting the hits, instead of a full scan. `values`
/// are sorted+deduped, so each existing row is counted once. When `residual_filter` is true, the full
/// `where_clause` is re-applied after each hit (a miss skips to the next value), so a rowid seek that
/// coexists with a predicate it cannot enforce still counts exactly; the residual's `?` placeholders
/// re-number to the same base each iteration. `false` (bare IN/eq) emits no filter → byte-identical.
/// bd-count-rowid-in / bd-count-rowid-eq (+ -residual).
#[allow(clippy::too_many_arguments)]
fn codegen_select_count_star_rowid_in(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
values: &[i64],
where_clause: Option<&Expr>,
residual_filter: bool,
) {
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(Opcode::Integer, 0, out_regs, 0, P4::None, 0);
let rowid_reg = b.alloc_reg();
// Residual `?` placeholders re-number to this base each iteration (the IN values are integer literals,
// so nothing before the filter consumes a placeholder). Read even when there is no residual.
let where_placeholder_base = b.current_anon_placeholder();
for &value in values {
let skip_label = b.emit_label();
b.emit_op(Opcode::Int64, 0, rowid_reg, 0, P4::Int64(value), 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
if residual_filter && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
b.emit_op(Opcode::AddImm, out_regs, 1, 0, P4::None, 0);
b.resolve_label(skip_label);
}
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
}
/// `SELECT COUNT(*) FROM t WHERE <rowid> = <non-integer-literal constant>` (placeholder / real / text):
/// `MustBeInt` coerces the bound value to INTEGER affinity — a non-exact integer (`2.5`, `'abc'`, NULL)
/// rejects to `skip` (count stays 0, exactly as SQLite), while `'5'` / `5.0` coerce to `5` — then one
/// `SeekRowid` counts the hit. bd-count-rowid-eq-coerced.
fn codegen_select_count_star_rowid_eq_coerced(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
target_expr: &Expr,
) {
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(Opcode::Integer, 0, out_regs, 0, P4::None, 0);
let rowid_reg = b.alloc_reg();
let skip = b.emit_label();
emit_expr(b, target_expr, rowid_reg, None);
b.emit_jump_to_label(Opcode::MustBeInt, rowid_reg, 0, skip, P4::None, 0);
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, skip, P4::None, 0);
b.emit_op(Opcode::AddImm, out_regs, 1, 0, P4::None, 0);
b.resolve_label(skip);
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
}
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_count_star_plus_sum(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
plan: &CountStarPlusSumPlan,
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let count_reg = out_regs + i32::try_from(plan.count_out_idx).unwrap_or_default();
let sum_reg = out_regs + i32::try_from(plan.sum_out_idx).unwrap_or_default();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(Opcode::Count, cursor, count_reg, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, sum_reg, 0, P4::None, 0);
let finalize_label = b.emit_label();
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, finalize_label, P4::None, 0);
let arg_reg = b.alloc_reg();
if plan.sum_is_rowid {
b.emit_op(Opcode::Rowid, cursor, arg_reg, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
cursor,
i32::try_from(plan.sum_col_idx.unwrap_or_default()).unwrap_or_default(),
arg_reg,
P4::None,
0,
);
}
b.emit_op(
Opcode::AggStep,
0,
arg_reg,
sum_reg,
P4::FuncName("SUM".to_owned()),
1,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (loop_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, loop_body, 0, P4::None, 0);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
sum_reg,
1,
0,
P4::FuncName("SUM".to_owned()),
0,
);
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 2, 0, P4::None, 0);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_group_by_rowid_bucket_sum(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
plan: &GroupByRowidBucketSumPlan,
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let group_reg = out_regs + i32::try_from(plan.group_out_idx).unwrap_or_default();
let sum_out_reg = out_regs + i32::try_from(plan.sum_out_idx).unwrap_or_default();
let divisor_reg = b.alloc_reg();
let rowid_reg = b.alloc_reg();
let cur_key_reg = b.alloc_reg();
let prev_key_reg = b.alloc_reg();
let sum_accum_reg = b.alloc_reg();
let sum_arg_reg = b.alloc_reg();
let have_group_reg = b.alloc_reg();
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(
Opcode::Int64,
0,
divisor_reg,
0,
P4::Int64(plan.group_divisor),
0,
);
b.emit_op(Opcode::Null, 0, prev_key_reg, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, sum_accum_reg, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, have_group_reg, 0, P4::None, 0);
let finalize_label = b.emit_label();
let compare_keys_label = b.emit_label();
let new_group_label = b.emit_label();
let first_row_label = b.emit_label();
let same_group_label = b.emit_label();
let scan_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, finalize_label, P4::None, 0);
b.emit_op(Opcode::Rowid, cursor, rowid_reg, 0, P4::None, 0);
b.emit_op(
Opcode::Divide,
divisor_reg,
rowid_reg,
cur_key_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::IfPos,
have_group_reg,
0,
compare_keys_label,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, first_row_label, P4::None, 0);
b.resolve_label(compare_keys_label);
b.emit_jump_to_label(
Opcode::Ne,
cur_key_reg,
prev_key_reg,
new_group_label,
P4::None,
0x80,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, same_group_label, P4::None, 0);
b.resolve_label(new_group_label);
b.emit_op(
Opcode::AggFinal,
sum_accum_reg,
1,
0,
P4::FuncName("SUM".to_owned()),
0,
);
b.emit_op(Opcode::Copy, prev_key_reg, group_reg, 0, P4::None, 0);
b.emit_op(Opcode::Copy, sum_accum_reg, sum_out_reg, 0, P4::None, 0);
b.emit_op(Opcode::ResultRow, out_regs, 2, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, sum_accum_reg, 0, P4::None, 0);
b.resolve_label(first_row_label);
b.emit_op(Opcode::Integer, 1, have_group_reg, 0, P4::None, 0);
b.resolve_label(same_group_label);
b.emit_op(Opcode::Copy, cur_key_reg, prev_key_reg, 0, P4::None, 0);
b.emit_op(
Opcode::Column,
cursor,
i32::try_from(plan.sum_col_idx).unwrap_or_default(),
sum_arg_reg,
P4::None,
0,
);
b.emit_op(
Opcode::AggStep,
0,
sum_arg_reg,
sum_accum_reg,
P4::FuncName("SUM".to_owned()),
1,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let scan_body = (scan_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, scan_body, 0, P4::None, 0);
let output_final_label = b.emit_label();
b.resolve_label(finalize_label);
b.emit_jump_to_label(
Opcode::IfPos,
have_group_reg,
0,
output_final_label,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(output_final_label);
b.emit_op(
Opcode::AggFinal,
sum_accum_reg,
1,
0,
P4::FuncName("SUM".to_owned()),
0,
);
b.emit_op(Opcode::Copy, prev_key_reg, group_reg, 0, P4::None, 0);
b.emit_op(Opcode::Copy, sum_accum_reg, sum_out_reg, 0, P4::None, 0);
b.emit_op(Opcode::ResultRow, out_regs, 2, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_count_star_indexed_in_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
_table_alias: Option<&str>,
schema: &[TableSchema],
out_regs: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
in_target: CountIndexedInTarget<'_>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let probe_cursor = cursor + 2;
let source_cursor = cursor + 3;
let count_key_collation = idx_schema
.key_term_collation(0)
.filter(|name| !name.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |name| P4::Collation(name.to_owned()));
if let CountIndexedInTarget::ProbeSource(probe_source) = &in_target
&& count_probe_source_can_skip_materialization(probe_source)
{
let use_exists_semijoin_merge =
count_exists_semijoin_merge_is_safe(table, idx_schema, probe_source);
let source_rowid_range = extract_safe_probe_source_rowid_range(probe_source);
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, out_regs, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
source_cursor,
probe_source.table.root_page,
0,
P4::Table(probe_source.table.name.clone()),
0,
);
let probe_done = b.emit_label();
if use_exists_semijoin_merge {
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, probe_done, P4::None, 0);
}
let source_upper_reg = source_rowid_range.and_then(|range| {
range.upper.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, probe_done, P4::None, 0);
reg
})
});
let source_upper_comparison = source_rowid_range.and_then(|range| {
range.upper.map(|bound| {
resolved_rowid_range_comparison(
probe_source.table,
probe_source.table_alias,
schema,
bound,
)
})
});
let source_rowid_reg = (source_rowid_range.is_some()
&& matches!(probe_source.value, InProbeValue::Rowid))
.then(|| b.alloc_reg());
let r_probe_value = source_rowid_reg.unwrap_or_else(|| b.alloc_reg());
let r_current_key = b.alloc_reg();
let probe_start = b.current_addr();
if let Some(range) = source_rowid_range {
if let Some(bound) = range.lower {
let lower_reg = b.alloc_reg();
emit_expr(b, bound.expr, lower_reg, None);
b.emit_jump_to_label(Opcode::IsNull, lower_reg, 0, probe_done, P4::None, 0);
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(
seek_opcode,
source_cursor,
lower_reg,
probe_done,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, source_cursor, 0, probe_done, P4::None, 0);
}
} else {
b.emit_jump_to_label(Opcode::Rewind, source_cursor, 0, probe_done, P4::None, 0);
}
let probe_loop_body = if let Some(rowid_reg) = source_rowid_reg {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_addr = b.current_addr() as i32;
b.emit_op(Opcode::Rowid, source_cursor, rowid_reg, 0, P4::None, 0);
loop_addr
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_addr = (probe_start + 1) as i32;
loop_addr
};
let skip_row = (probe_source.where_clause.is_some() && source_rowid_range.is_none())
.then(|| b.emit_label());
if let Some(range) = source_rowid_range
&& let Some(bound) = range.upper
{
let current_rowid_reg = source_rowid_reg.unwrap_or_else(|| b.alloc_reg());
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_jump_to_label(
stop_opcode,
source_upper_reg.expect("upper bound register should exist"),
current_rowid_reg,
probe_done,
source_upper_comparison
.as_ref()
.map_or(P4::None, |comparison| comparison.collation_p4.clone()),
source_upper_comparison
.as_ref()
.map_or(0, |comparison| comparison.cmp_p5),
);
}
if let (Some(where_expr), Some(skip_label)) = (probe_source.where_clause, skip_row) {
emit_where_filter(
b,
where_expr,
source_cursor,
probe_source.table,
probe_source.table_alias,
schema,
skip_label,
);
}
let probe_scan = ScanCtx {
cursor: source_cursor,
table: probe_source.table,
table_alias: probe_source.table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
if source_rowid_reg.is_none() {
emit_in_probe_value(b, source_cursor, probe_source, r_probe_value, &probe_scan);
}
let next_probe = b.emit_label();
if source_rowid_reg.is_none() {
b.emit_jump_to_label(Opcode::IsNull, r_probe_value, 0, next_probe, P4::None, 0);
}
if use_exists_semijoin_merge {
let advance_outer = b.emit_label();
let align_outer = b.emit_label();
b.resolve_label(align_outer);
b.emit_op(Opcode::Column, idx_cursor, 0, r_current_key, P4::None, 0);
// NULL is never equal to a rowid probe. Skip nullable leading
// index keys explicitly: comparison opcodes intentionally do not
// order NULL against an integer for SQL predicate purposes, so
// omitting this edge would strand the outer cursor on its NULL run.
b.emit_jump_to_label(Opcode::IsNull, r_current_key, 0, advance_outer, P4::None, 0);
b.emit_jump_to_label(
Opcode::Lt,
r_probe_value,
r_current_key,
advance_outer,
count_key_collation.clone(),
0,
);
b.emit_jump_to_label(
Opcode::Gt,
r_probe_value,
r_current_key,
next_probe,
count_key_collation.clone(),
0,
);
b.emit_op(
Opcode::CountIndexEqRun,
idx_cursor,
out_regs,
r_probe_value,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::IfNullRow, idx_cursor, 0, probe_done, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_probe, P4::None, 0);
b.resolve_label(advance_outer);
b.emit_jump_to_label(Opcode::Next, idx_cursor, 0, align_outer, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, probe_done, P4::None, 0);
} else {
// bd-2fong (latent, found during the red-5 DESC-seek trace):
// MakeRecord packs CONSECUTIVE registers, but r_probe_value was
// allocated far earlier with r_current_key adjacent to it — so
// `MakeRecord(r_probe_value, 2, ..)` packed [probe, stale index
// key] while the i64::MIN floor below was written to a register
// nothing read. Build the (key, i64::MIN) seek record from a
// fresh adjacent pair instead.
let r_seek_base = b.alloc_regs(2);
let r_probe_record = b.alloc_reg();
b.emit_op(Opcode::SCopy, r_probe_value, r_seek_base, 0, P4::None, 0);
b.emit_op(Opcode::Int64, 0, r_seek_base + 1, 0, P4::Int64(i64::MIN), 0);
b.emit_op(
Opcode::MakeRecord,
r_seek_base,
2,
r_probe_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
r_probe_record,
next_probe,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
b.emit_op(Opcode::Column, idx_cursor, 0, r_current_key, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
r_probe_value,
r_current_key,
next_probe,
count_key_collation.clone(),
0,
);
b.emit_op(Opcode::AddImm, out_regs, 1, 0, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
}
if let Some(skip_label) = skip_row {
b.resolve_label(skip_label);
}
b.resolve_label(next_probe);
b.emit_op(Opcode::Next, source_cursor, probe_loop_body, 0, P4::None, 0);
b.resolve_label(probe_done);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, source_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
return Ok(());
}
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, out_regs, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_op(
Opcode::OpenAutoindex,
probe_cursor,
1,
0,
count_key_collation.clone(),
0,
);
let use_materialized_semijoin_merge = matches!(
&in_target,
CountIndexedInTarget::MaterializedProbeSource(probe_source)
if count_exists_semijoin_merge_is_safe(table, idx_schema, probe_source)
);
let r_value = b.alloc_temp();
let r_key = b.alloc_temp();
match in_target {
CountIndexedInTarget::List(values) => {
for value_expr in values {
emit_expr(b, value_expr, r_value, None);
let next_value = b.emit_label();
let skip_insert = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_value, 0, next_value, P4::None, 0);
b.emit_op(Opcode::MakeRecord, r_value, 1, r_key, P4::None, 0);
b.emit_jump_to_label(Opcode::Found, probe_cursor, r_key, skip_insert, P4::None, 0);
b.emit_op(Opcode::IdxInsert, probe_cursor, r_key, 0, P4::None, 0);
b.resolve_label(skip_insert);
b.resolve_label(next_value);
}
}
CountIndexedInTarget::ProbeSource(probe_source)
| CountIndexedInTarget::MaterializedProbeSource(probe_source) => {
b.emit_op(
Opcode::OpenRead,
source_cursor,
probe_source.table.root_page,
0,
P4::Table(probe_source.table.name.clone()),
0,
);
let build_done = b.emit_label();
let source_rowid_range = extract_safe_probe_source_rowid_range(&probe_source);
let source_upper_reg = source_rowid_range.and_then(|range| {
range.upper.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, build_done, P4::None, 0);
reg
})
});
let source_upper_comparison = source_rowid_range.and_then(|range| {
range.upper.map(|bound| {
resolved_rowid_range_comparison(
probe_source.table,
probe_source.table_alias,
schema,
bound,
)
})
});
let build_start = b.current_addr();
if let Some(range) = source_rowid_range {
if let Some(bound) = range.lower {
let lower_reg = b.alloc_reg();
emit_expr(b, bound.expr, lower_reg, None);
b.emit_jump_to_label(Opcode::IsNull, lower_reg, 0, build_done, P4::None, 0);
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(
seek_opcode,
source_cursor,
lower_reg,
build_done,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, source_cursor, 0, build_done, P4::None, 0);
}
} else {
b.emit_jump_to_label(Opcode::Rewind, source_cursor, 0, build_done, P4::None, 0);
}
let skip_row = probe_source.where_clause.map(|_| b.emit_label());
if let Some(range) = source_rowid_range
&& let Some(bound) = range.upper
{
let current_rowid_reg = b.alloc_reg();
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_op(
Opcode::Rowid,
source_cursor,
current_rowid_reg,
0,
P4::None,
0,
);
b.emit_jump_to_label(
stop_opcode,
source_upper_reg.expect("upper bound register should exist"),
current_rowid_reg,
build_done,
source_upper_comparison
.as_ref()
.map_or(P4::None, |comparison| comparison.collation_p4.clone()),
source_upper_comparison
.as_ref()
.map_or(0, |comparison| comparison.cmp_p5),
);
}
if let (Some(where_expr), Some(skip_label)) = (probe_source.where_clause, skip_row) {
emit_where_filter(
b,
where_expr,
source_cursor,
probe_source.table,
probe_source.table_alias,
schema,
skip_label,
);
}
let probe_scan = ScanCtx {
cursor: source_cursor,
table: probe_source.table,
table_alias: probe_source.table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_in_probe_value(b, source_cursor, &probe_source, r_value, &probe_scan);
let skip_insert = b.emit_label();
let next_value = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_value, 0, next_value, P4::None, 0);
b.emit_op(Opcode::MakeRecord, r_value, 1, r_key, P4::None, 0);
b.emit_jump_to_label(Opcode::Found, probe_cursor, r_key, skip_insert, P4::None, 0);
b.emit_op(Opcode::IdxInsert, probe_cursor, r_key, 0, P4::None, 0);
b.resolve_label(skip_insert);
b.resolve_label(next_value);
if let Some(skip_label) = skip_row {
b.resolve_label(skip_label);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let build_loop_body = (build_start + 1) as i32;
b.emit_op(Opcode::Next, source_cursor, build_loop_body, 0, P4::None, 0);
b.resolve_label(build_done);
b.emit_op(Opcode::Close, source_cursor, 0, 0, P4::None, 0);
}
}
b.free_temp(r_key);
b.free_temp(r_value);
b.emit_jump_to_label(Opcode::Rewind, probe_cursor, 0, done_label, P4::None, 0);
if use_materialized_semijoin_merge {
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, done_label, P4::None, 0);
let r_probe_value = b.alloc_reg();
let r_current_key = b.alloc_reg();
let probe_loop_top = b.current_addr();
b.emit_op(Opcode::Column, probe_cursor, 0, r_probe_value, P4::None, 0);
let next_probe = b.emit_label();
let advance_outer = b.emit_label();
let align_outer = b.emit_label();
b.resolve_label(align_outer);
b.emit_op(Opcode::Column, idx_cursor, 0, r_current_key, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_current_key, 0, advance_outer, P4::None, 0);
b.emit_jump_to_label(
Opcode::Lt,
r_probe_value,
r_current_key,
advance_outer,
count_key_collation.clone(),
0,
);
b.emit_jump_to_label(
Opcode::Gt,
r_probe_value,
r_current_key,
next_probe,
count_key_collation.clone(),
0,
);
b.emit_op(
Opcode::CountIndexEqRun,
idx_cursor,
out_regs,
r_probe_value,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::IfNullRow, idx_cursor, 0, done_label, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_probe, P4::None, 0);
b.resolve_label(advance_outer);
b.emit_jump_to_label(Opcode::Next, idx_cursor, 0, align_outer, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next_probe);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let probe_loop_body = probe_loop_top as i32;
b.emit_op(Opcode::Next, probe_cursor, probe_loop_body, 0, P4::None, 0);
} else {
let r_probe_value = b.alloc_reg();
let r_min_rowid = b.alloc_reg();
let r_probe_record = b.alloc_reg();
let r_current_key = b.alloc_reg();
let probe_loop_top = b.current_addr();
b.emit_op(Opcode::Column, probe_cursor, 0, r_probe_value, P4::None, 0);
b.emit_op(Opcode::Int64, 0, r_min_rowid, 0, P4::Int64(i64::MIN), 0);
b.emit_op(
Opcode::MakeRecord,
r_probe_value,
2,
r_probe_record,
P4::None,
0,
);
let next_probe = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
r_probe_record,
next_probe,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
b.emit_op(Opcode::Column, idx_cursor, 0, r_current_key, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
r_probe_value,
r_current_key,
next_probe,
count_key_collation,
0,
);
b.emit_op(Opcode::AddImm, out_regs, 1, 0, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.resolve_label(next_probe);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let probe_loop_body = probe_loop_top as i32;
b.emit_op(Opcode::Next, probe_cursor, probe_loop_body, 0, P4::None, 0);
}
b.resolve_label(done_label);
b.emit_op(Opcode::ResultRow, out_regs, 1, 0, P4::None, 0);
b.emit_op(Opcode::Close, probe_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
fn extract_safe_probe_source_rowid_range<'a>(
probe_source: &InProbeSource<'a>,
) -> Option<RowidRangeTarget<'a>> {
let range = extract_rowid_range_target(
probe_source.where_clause,
Some(probe_source.table),
probe_source.table_alias,
)?;
rowid_range_fast_path_is_safe(range).then_some(range)
}
fn count_exists_semijoin_merge_is_safe(
table: &TableSchema,
idx_schema: &IndexSchema,
probe_source: &InProbeSource<'_>,
) -> bool {
if !matches!(probe_source.value, InProbeValue::Rowid) {
return false;
}
count_rowid_probe_index_is_seek_compatible(table, idx_schema)
}
fn count_rowid_probe_index_is_seek_compatible(
table: &TableSchema,
idx_schema: &IndexSchema,
) -> bool {
idx_schema
.columns
.first()
.and_then(|column_name| table.column_index(column_name))
.and_then(|column_idx| table.columns.get(column_idx))
.is_some_and(|column| column.is_ipk || matches!(column.affinity, 'D' | 'd' | 'C'))
}
fn resolved_rowid_range_comparison(
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
bound: RowidRangeBound<'_>,
) -> ResolvedComparisonInfo {
let scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
ResolvedComparisonInfo::new(bound.rowid_expr, bound.expr, &scan)
}
fn resolved_index_range_comparison(
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
column_name: &str,
bound_expr: &Expr,
) -> ResolvedComparisonInfo {
let column_expr = Expr::Column(ColumnRef::bare(column_name), Span::ZERO);
let scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
ResolvedComparisonInfo::new(&column_expr, bound_expr, &scan)
}
fn emit_covering_output_reads(
b: &mut ProgramBuilder,
index_cursor: i32,
rowid_reg: i32,
sources: &[CoveringOutputSource],
out_regs: i32,
) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (offset, source) in sources.iter().enumerate() {
let target_reg = out_regs + offset as i32;
match source {
CoveringOutputSource::IndexColumn(index_col) => {
b.emit_op(
Opcode::Column,
index_cursor,
*index_col,
target_reg,
P4::None,
0,
);
}
CoveringOutputSource::Rowid => {
b.emit_op(Opcode::Copy, rowid_reg, target_reg, 0, P4::None, 0);
}
}
}
}
/// Generate VDBE bytecode for an ORDER BY scan that can stream rows directly
/// from an index in sorted order (no sorter temp B-tree).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_index_ordered_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
index_plan: &OrderByIndexPlan,
) -> Result<(), CodegenError> {
let index_cursor = cursor + 1;
let needs_table_lookup = index_plan.covering_output.is_none() || where_clause.is_some();
let where_placeholder_base = b.current_anon_placeholder();
let equality_prefix_exprs = if index_plan.equality_prefix_len == 0 {
Vec::new()
} else {
extract_index_equality_prefix_exprs(&index_plan.index, table, table_alias, where_clause)
};
let use_bounded_prefix_scan = !index_plan.descending
&& index_plan.equality_prefix_len > 0
&& equality_prefix_exprs.len() >= index_plan.equality_prefix_len;
// Allocate LIMIT/OFFSET counter registers (if present).
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
if needs_table_lookup {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
index_cursor,
index_plan.index.root_page,
0,
P4::Index(index_plan.index.name.clone()),
0,
);
let loop_start = if use_bounded_prefix_scan {
let probe_key_regs = b.alloc_regs((index_plan.index.key_term_count() + 1) as i32);
for (offset, expr) in equality_prefix_exprs
.iter()
.take(index_plan.equality_prefix_len)
.enumerate()
{
let reg = probe_key_regs + offset as i32;
emit_expr(b, expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, done_label, P4::None, 0);
}
for offset in index_plan.equality_prefix_len..index_plan.index.key_term_count() {
b.emit_op(
Opcode::Null,
0,
probe_key_regs + offset as i32,
0,
P4::None,
0,
);
}
b.emit_op(
Opcode::Int64,
0,
probe_key_regs + index_plan.index.key_term_count() as i32,
0,
P4::Int64(i64::MIN),
0,
);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
(index_plan.index.key_term_count() + 1) as i32,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
index_cursor,
probe_record_reg,
done_label,
P4::None,
0,
);
let loop_start = b.current_addr();
b.emit_jump_to_label(
Opcode::IdxGT,
index_cursor,
probe_record_reg,
done_label,
P4::None,
index_plan.equality_prefix_len as u16,
);
loop_start
} else {
let loop_start = b.current_addr();
if index_plan.descending {
b.emit_jump_to_label(Opcode::Last, index_cursor, 0, done_label, P4::None, 0);
} else {
b.emit_jump_to_label(Opcode::Rewind, index_cursor, 0, done_label, P4::None, 0);
}
loop_start
};
let skip_row = b.emit_label();
let rowid_reg = b.alloc_reg();
// WITHOUT ROWID index entries carry a PK suffix instead of a trailing
// rowid (bd-rjaff): seek the table b-tree by the PK columns read from the
// index entry. Covering reads on WITHOUT ROWID never resolve a Rowid
// source (rowid aliases do not resolve on WITHOUT ROWID tables), so
// `rowid_reg` staying unwritten is safe there.
let wr_pk_indices = if table.without_rowid {
Some(without_rowid_pk_indices(table)?)
} else {
None
};
if let Some(pk_indices) = wr_pk_indices.as_ref() {
if needs_table_lookup {
emit_without_rowid_index_to_table_seek(
b,
table,
cursor,
index_cursor,
&index_plan.index,
pk_indices,
skip_row,
);
}
} else {
b.emit_op(Opcode::IdxRowid, index_cursor, rowid_reg, 0, P4::None, 0);
if needs_table_lookup {
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, skip_row, P4::None, 0);
}
}
if let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(b, where_expr, cursor, table, table_alias, schema, skip_row);
}
if let Some(covering_output) = &index_plan.covering_output {
emit_covering_output_reads(b, index_cursor, rowid_reg, covering_output, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
// OFFSET: if offset counter > 0, decrement by 1 and skip this row.
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_row, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
// LIMIT: decrement limit counter; jump to done when zero.
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_row);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = if use_bounded_prefix_scan {
loop_start as i32
} else {
(loop_start + 1) as i32
};
if index_plan.descending {
b.emit_op(Opcode::Prev, index_cursor, loop_body, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Next, index_cursor, loop_body, 0, P4::None, 0);
}
b.resolve_label(done_label);
b.emit_op(Opcode::Close, index_cursor, 0, 0, P4::None, 0);
if needs_table_lookup {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
Ok(())
}
/// Generate VDBE bytecode for `SELECT DISTINCT` without ORDER BY.
///
/// Preserves source scan order by recording each projected tuple in an
/// ephemeral membership index. LIMIT/OFFSET apply only after a tuple is known
/// to be distinct, matching SQLite's first-occurrence semantics.
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn codegen_select_distinct_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let num_data_cols = result_column_count_usize(columns, table);
let distinct_cursor = cursor + 1;
let distinct_collations = (0..num_data_cols)
.map(|slot| {
result_output_slot_collation(slot, columns, table, table_alias)
.unwrap_or_else(|| "BINARY".to_owned())
})
.collect::<Vec<_>>()
.join(",");
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
b.emit_op(
Opcode::OpenAutoindex,
distinct_cursor,
out_col_count,
0,
P4::Str(distinct_collations),
0,
);
// Open table for reading.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
let scan_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, done_label, P4::None, 0);
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
let distinct_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
out_regs,
out_col_count,
distinct_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::Found,
distinct_cursor,
distinct_record,
skip_label,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
distinct_cursor,
distinct_record,
0,
P4::None,
0,
);
b.free_temp(distinct_record);
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, skip_label, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(skip_label);
let scan_body = (scan_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, scan_body, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, distinct_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit a LIMIT or OFFSET expression into a register.
///
/// Handles integer literals and bind parameters; evaluates arbitrary
/// expressions via `emit_expr` for computed limits (e.g. `LIMIT 5+0`).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_limit_expr(b: &mut ProgramBuilder, expr: &Expr, target_reg: i32) {
match expr {
Expr::Literal(Literal::Integer(n), _) => {
if let Ok(as_i32) = i32::try_from(*n) {
b.emit_op(Opcode::Integer, as_i32, target_reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Int64, 0, target_reg, 0, P4::Int64(*n), 0);
}
}
Expr::Placeholder(pt, _) => {
let param_idx = match pt {
fsqlite_ast::PlaceholderType::Numbered(n) => *n as i32,
// Anonymous and named placeholders use sequential numbering.
_ => b.next_anon_placeholder_idx() as i32,
};
b.emit_op(Opcode::Variable, param_idx, target_reg, 0, P4::None, 0);
}
_ => {
// Evaluate arbitrary expression (e.g. `5+0`, `abs(-3)`).
// No table context needed — LIMIT expressions don't reference columns.
emit_expr(b, expr, target_reg, None);
}
}
}
/// Whether LIMIT/OFFSET cannot suppress a single aggregate output row.
///
/// This lets single-seek aggregate fast paths retain their existing complexity
/// for common `LIMIT 1` probes. Dynamic or coercible expressions take the
/// general limit-aware path because their value is known only at execution.
fn single_group_aggregate_limit_is_output_neutral(limit_clause: Option<&LimitClause>) -> bool {
let Some(clause) = limit_clause else {
return true;
};
let Expr::Literal(Literal::Integer(limit), _) = &clause.limit else {
return false;
};
if *limit == 0 {
return false;
}
clause.offset.as_ref().is_none_or(
|offset| matches!(offset, Expr::Literal(Literal::Integer(value), _) if *value <= 0),
)
}
/// Emit a guard that jumps to `done_label` when the LIMIT register is zero.
///
/// `LIMIT 0` means "return no rows". The `DecrJumpZero` instruction after
/// `ResultRow` doesn't fire when the register is already 0 (it only
/// decrements positive values), so we need a pre-loop guard.
///
/// `LIMIT -1` means "no limit" — the register is -1 (truthy), so the
/// `IfNot` check does not fire.
fn emit_limit_zero_guard(b: &mut ProgramBuilder, limit_reg: i32, done_label: crate::Label) {
b.emit_jump_to_label(Opcode::IfNot, limit_reg, 1, done_label, P4::None, 0);
}
/// Emit LIMIT/OFFSET registers with SQLite's evaluation and coercion order.
///
/// LIMIT is evaluated and losslessly coerced to an integer first. A zero LIMIT
/// jumps directly to `done_label`, so OFFSET is not evaluated at all. Only a
/// nonzero LIMIT reaches OFFSET evaluation and its own integer coercion.
fn emit_limit_offset_registers(
b: &mut ProgramBuilder,
limit_clause: Option<&LimitClause>,
done_label: crate::Label,
) -> (Option<i32>, Option<i32>) {
let Some(clause) = limit_clause else {
return (None, None);
};
let limit_reg = b.alloc_reg();
emit_limit_expr(b, &clause.limit, limit_reg);
if !matches!(&clause.limit, Expr::Literal(Literal::Integer(_), _)) {
b.emit_op(Opcode::MustBeInt, limit_reg, 0, 0, P4::None, 0);
}
emit_limit_zero_guard(b, limit_reg, done_label);
let offset_reg = clause.offset.as_ref().map(|offset| {
let register = b.alloc_reg();
emit_limit_expr(b, offset, register);
if !matches!(offset, Expr::Literal(Literal::Integer(_), _)) {
b.emit_op(Opcode::MustBeInt, register, 0, 0, P4::None, 0);
}
register
});
(Some(limit_reg), offset_reg)
}
/// Compute the runtime row-retention bound for a top-N sorter.
///
/// Pass 2 emits at most `LIMIT` rows after skipping `OFFSET`, so pass 1 must
/// retain `LIMIT + max(OFFSET, 0)` rows. `OffsetLimit` preserves negative
/// LIMIT as the no-limit sentinel and saturates positive overflow. The
/// returned register must be read by `SorterOpen` with
/// `SORTER_OPEN_TOP_N_REGISTER`.
fn emit_top_n_bound_register(
b: &mut ProgramBuilder,
limit_reg: Option<i32>,
offset_reg: Option<i32>,
) -> Option<i32> {
let limit_reg = limit_reg?;
let Some(offset_reg) = offset_reg else {
return Some(limit_reg);
};
// SQLite treats a negative OFFSET as zero. Clamp the shared pass-2 counter
// before computing the retention bound so both phases observe the same
// normalized value.
let zero_reg = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, zero_reg, 0, P4::None, 0);
b.emit_op(Opcode::MemMax, zero_reg, offset_reg, 0, P4::None, 0);
b.free_temp(zero_reg);
let bound_reg = b.alloc_reg();
b.emit_op(
Opcode::OffsetLimit,
limit_reg,
offset_reg,
bound_reg,
P4::None,
0,
);
Some(bound_reg)
}
fn limit_clause_can_enable_top_n(limit_clause: Option<&LimitClause>) -> bool {
limit_clause
.is_some_and(|clause| order_by_integer_ordinal(&clause.limit).is_none_or(|limit| limit > 0))
}
// ---------------------------------------------------------------------------
// ORDER BY codegen (two-pass sorter)
// ---------------------------------------------------------------------------
/// Generate VDBE bytecode for a full-scan SELECT with ORDER BY.
///
/// Uses a two-pass sorter approach:
/// 1. Scan table rows (with WHERE), pack sort-key + data columns into sorter.
/// 2. After sorting, iterate sorted rows and emit `ResultRow`.
///
/// LIMIT/OFFSET are applied in pass 2 (on sorted output).
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments,
clippy::too_many_lines
)]
fn codegen_select_ordered_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
order_by: &[OrderingTerm],
limit_clause: Option<&LimitClause>,
distinct: Distinctness,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
// Connection-level compilation canonicalizes anonymous and named bind
// parameters to explicit `?NNN` slots before VDBE codegen. Direct callers
// can still provide a raw AST, though, and this path intentionally emits
// LIMIT/OFFSET before the earlier textual SELECT/WHERE/ORDER expressions.
// Sequential emission-order numbering would therefore reverse slots or
// break named-parameter reuse. Decline the reordered path unless every
// placeholder already carries its canonical numeric slot.
let projection_has_non_numbered_placeholder = columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. } if expr_contains_non_numbered_placeholder(expr)
)
});
let order_has_non_numbered_placeholder = order_by
.iter()
.any(|term| expr_contains_non_numbered_placeholder(&term.expr));
let limit_has_non_numbered_placeholder = limit_clause.is_some_and(|clause| {
expr_contains_non_numbered_placeholder(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(expr_contains_non_numbered_placeholder)
});
if projection_has_non_numbered_placeholder
|| where_clause.is_some_and(expr_contains_non_numbered_placeholder)
|| order_has_non_numbered_placeholder
|| limit_has_non_numbered_placeholder
{
return Err(CodegenError::Unsupported(
"ordered SELECT codegen requires canonical numbered bind parameters".to_owned(),
));
}
// LIMIT (then OFFSET) is evaluated before the source scan. Besides
// matching SQLite's observable error/function order, this supplies the
// runtime retention bound used by the bounded sorter.
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let top_n_bound_reg = if limit_clause_can_enable_top_n(limit_clause) {
emit_top_n_bound_register(b, limit_reg, offset_reg)
} else {
None
};
// Resolve ORDER BY sources (column indices, rowid, or expressions).
let sort_keys: Vec<SortKeySource> = order_by
.iter()
.map(|term| resolve_sort_key(&term.expr, table, table_alias, columns))
.collect();
let order_output_slots: Vec<Option<usize>> = order_by
.iter()
.map(|term| resolve_order_by_output_slot(&term.expr, columns, table, table_alias))
.collect();
let num_sort_keys = sort_keys.len();
let num_data_cols = result_column_count_usize(columns, table);
// Sorter cursor is separate from the table cursor.
let sorter_cursor = cursor + 1;
// Open sorter: p2 = number of key columns, p4 = sort order + collation.
// Sort order chars: '+' = ASC (nulls first), '-' = DESC (nulls last),
// '>' = ASC NULLS LAST, '<' = DESC NULLS FIRST.
let sort_order: String = order_by
.iter()
.map(|term| {
let is_desc = term.direction == Some(SortDirection::Desc);
let nulls_last = match term.nulls {
Some(NullsOrder::Last) => true,
Some(NullsOrder::First) => false,
None => is_desc, // SQLite default: ASC→nulls first, DESC→nulls last
};
match (is_desc, nulls_last) {
(false, false) => '+', // ASC NULLS FIRST (default)
(false, true) => '>', // ASC NULLS LAST
(true, true) => '-', // DESC NULLS LAST (default)
(true, false) => '<', // DESC NULLS FIRST
}
})
.collect();
// Build per-key collation info from the resolved sort keys.
let sort_collations: Vec<String> = sort_keys
.iter()
.zip(order_by.iter())
.enumerate()
.map(|(index, (sk, term))| {
// Explicit COLLATE on the ORDER BY term takes priority.
if let Some(collation) = extract_collation(&term.expr) {
return collation.to_owned();
}
if let Some(output_slot) = order_output_slots[index]
&& let Some(collation) =
result_output_slot_collation(output_slot, columns, table, table_alias)
{
return collation;
}
// Otherwise, inherit the column's declared collation.
match sk {
SortKeySource::Column(idx) => {
if let Some(collation) =
table.columns.get(*idx).and_then(|c| c.collation.as_deref())
{
return collation.to_owned();
}
}
SortKeySource::Expression(expr) => {
if let Some(collation) = extract_collation(expr)
.or_else(|| column_collation(expr, table, table_alias))
{
return collation.to_owned();
}
}
SortKeySource::Rowid => {}
}
String::new()
})
.collect();
let distinct_projection_mode = ordered_distinct_projection_mode(
distinct,
order_by,
&order_output_slots,
&sort_collations,
columns,
table,
table_alias,
);
let stored_data_cols = match distinct_projection_mode {
OrderedDistinctProjectionMode::StoredOutput => num_data_cols,
OrderedDistinctProjectionMode::ReprojectRepresentative if table.without_rowid => {
table.columns.len()
}
OrderedDistinctProjectionMode::ReprojectRepresentative => 1,
};
let total_sorter_cols = num_sort_keys + stored_data_cols;
let keep_source_cursor_open = distinct_projection_mode
== OrderedDistinctProjectionMode::ReprojectRepresentative
&& !table.without_rowid;
let has_collation = sort_collations.iter().any(|c| !c.is_empty());
let p4_str = if has_collation {
format!("{sort_order}|{}", sort_collations.join(","))
} else {
sort_order
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
num_sort_keys as i32,
top_n_bound_reg.unwrap_or(0),
P4::Str(p4_str),
top_n_bound_reg.map_or(0, |_| SORTER_OPEN_TOP_N_REGISTER),
);
// DISTINCT is defined over the flattened output tuple, not the ORDER BY
// key prefix. Open a separate membership index before pass 1 so duplicate
// outputs can skip independent ORDER BY expression evaluation as well as
// sorter insertion. Spell out BINARY for default-collation slots because
// the OpenAutoindex P4 parser intentionally ignores empty list entries.
let distinct_cursor = if distinct == Distinctness::Distinct {
let distinct_cursor = sorter_cursor + 1;
let collations = (0..num_data_cols)
.map(|slot| {
result_output_slot_collation(slot, columns, table, table_alias)
.unwrap_or_else(|| "BINARY".to_owned())
})
.collect::<Vec<_>>()
.join(",");
b.emit_op(
Opcode::OpenAutoindex,
distinct_cursor,
out_col_count,
0,
P4::Str(collations),
0,
);
Some(distinct_cursor)
} else {
None
};
// Open table for reading.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// === Pass 1: Scan rows into sorter ===
let scan_start = b.current_addr();
let scan_done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, scan_done, P4::None, 0);
// WHERE filter.
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
// Read sort-key columns + data columns into consecutive registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let sorter_base = b.alloc_regs(total_sorter_cols as i32);
let stored_data_base = sorter_base + num_sort_keys as i32;
let output_base =
if distinct_projection_mode == OrderedDistinctProjectionMode::ReprojectRepresentative {
b.alloc_regs(out_col_count)
} else {
stored_data_base
};
let scan = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
{
let mut output_emitted = vec![false; num_data_cols];
// In the ordinary (non-merged) SQLite DISTINCT+ORDER BY shape, result
// expressions are evaluated and deduplicated before independent ORDER
// expressions. Besides being the correct tuple key, this ordering means
// a duplicate output cannot invoke a volatile or failing sort-only
// expression. Exact ORDER aliases/ordinals below reuse these registers.
if let Some(distinct_cursor) = distinct_cursor {
emit_column_reads_selected(b, &scan, columns, output_base, |_| true)?;
output_emitted.fill(true);
let distinct_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
output_base,
out_col_count,
distinct_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::Found,
distinct_cursor,
distinct_record,
skip_label,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
distinct_cursor,
distinct_record,
0,
P4::None,
0,
);
b.free_temp(distinct_record);
}
for (index, (reg, key)) in (sorter_base..).zip(sort_keys.iter()).enumerate() {
if let Some(output_slot) = order_output_slots[index] {
if !output_emitted[output_slot] {
emit_column_reads_selected(b, &scan, columns, output_base, |slot| {
slot == output_slot
})?;
output_emitted[output_slot] = true;
}
b.emit_op(
Opcode::Copy,
output_base + output_slot as i32,
reg,
0,
P4::None,
0,
);
} else {
emit_resolved_column(b, key, cursor, reg, &scan);
}
}
if top_n_bound_reg.is_some() {
let key_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
sorter_base,
num_sort_keys as i32,
key_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SorterCompare,
sorter_cursor,
key_record,
skip_label,
P4::None,
SORTER_COMPARE_TOP_N_PREFLIGHT,
);
b.free_temp(key_record);
}
// For non-DISTINCT top-N, evaluate only outputs not already shared with
// an exact ORDER BY key. This occurs after admission, so rejected rows
// cannot invoke volatile or failing payload expressions. DISTINCT
// output was already evaluated above, before its membership probe.
emit_column_reads_selected(b, &scan, columns, output_base, |slot| !output_emitted[slot])?;
if distinct_projection_mode == OrderedDistinctProjectionMode::ReprojectRepresentative {
emit_ordered_distinct_source_state(b, cursor, table, stored_data_base);
}
}
// MakeRecord from all sorter columns, then SorterInsert.
let record_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::MakeRecord,
sorter_base,
total_sorter_cols as i32,
record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sorter_cursor,
record_reg,
0,
P4::None,
0,
);
// Skip label (for WHERE-filtered rows).
b.resolve_label(skip_label);
// Next row in scan.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let scan_body = (scan_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, scan_body, 0, P4::None, 0);
// End of pass 1. A merged ordered-DISTINCT query on a rowid table keeps
// this read cursor open so pass 2 can seek and re-evaluate the selected
// expressions for each retained representative.
b.resolve_label(scan_done);
if !keep_source_cursor_open {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
// === Pass 2: Iterate the already-deduplicated sorted rows ===
// SorterSort: sort and position at first row; jump to done if empty.
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
done_label,
P4::None,
0,
);
// Save the address of the sort loop body (SorterNext returns here).
let sort_loop_body = b.current_addr();
// OFFSET applies after duplicate elimination and before materializing the
// retained sorter payload or restoring/reprojecting its source row.
let output_skip = b.emit_label();
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, output_skip, P4::None, 0);
}
// SorterData: decode current sorted row into a register.
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
match distinct_projection_mode {
OrderedDistinctProjectionMode::StoredOutput => {
// The sorter record has sort-key columns first, followed by the
// already-projected result tuple.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_data_cols {
let src_col = (num_sort_keys + i) as i32;
b.emit_op(
Opcode::Column,
sorter_cursor,
src_col,
out_regs + i as i32,
P4::None,
0,
);
}
}
OrderedDistinctProjectionMode::ReprojectRepresentative if table.without_rowid => {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let source_base = b.alloc_regs(table.columns.len() as i32);
for column_index in 0..table.columns.len() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
sorter_cursor,
(num_sort_keys + column_index) as i32,
source_base + column_index as i32,
P4::None,
0,
);
}
emit_projection_from_register_row(
b,
columns,
table,
table_alias,
schema,
source_base,
out_regs,
)?;
}
OrderedDistinctProjectionMode::ReprojectRepresentative => {
let representative_rowid = b.alloc_reg();
b.emit_op(
Opcode::Column,
sorter_cursor,
num_sort_keys as i32,
representative_rowid,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
representative_rowid,
output_skip,
P4::None,
0,
);
emit_column_reads_selected(b, &scan, columns, out_regs, |_| true)?;
}
}
// ResultRow.
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
// LIMIT: decrement limit counter; jump to done when zero.
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
// Output skip label (for OFFSET-skipped rows).
b.resolve_label(output_skip);
// SorterNext: advance to next sorted row, jump back to sort loop body.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
sort_loop_body as i32,
0,
P4::None,
0,
);
// Done: Close sorter + Halt.
b.resolve_label(done_label);
if keep_source_cursor_open {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
if let Some(distinct_cursor) = distinct_cursor {
b.emit_op(Opcode::Close, distinct_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
Ok(())
}
// ---------------------------------------------------------------------------
// Aggregate codegen
// ---------------------------------------------------------------------------
/// Known aggregate function names (case-insensitive matching).
const AGGREGATE_FUNCTIONS: &[&str] = &[
"avg",
"count",
"group_concat",
"string_agg",
// JSON1 collection aggregates (fsqlite-ext-json, always registered). They
// have no scalar overload, so the name-only predicate is safe — the wrong
// arity is rejected downstream by `find_aggregate`.
"json_group_array",
"json_group_object",
"max",
"min",
"sum",
"total",
"median",
"percentile",
"percentile_cont",
"percentile_disc",
];
/// Check whether a function name is a known built-in aggregate.
///
/// Custom aggregates are resolved separately by exact/variadic arity in
/// [`is_aggregate_function_call`]. Folding them into this name-only predicate
/// would make registering `custom/1` incorrectly classify `custom/2` as an
/// aggregate call.
fn is_aggregate_function(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
AGGREGATE_FUNCTIONS.contains(&lower.as_str())
}
/// Check whether any result column contains an aggregate function call.
fn has_aggregate_columns(columns: &[ResultColumn]) -> bool {
columns.iter().any(|col| {
if let ResultColumn::Expr { expr, .. } = col {
is_aggregate_expr(expr)
} else {
false
}
})
}
/// Check whether any result column contains a window function call.
fn has_window_columns(columns: &[ResultColumn]) -> bool {
columns.iter().any(|col| {
if let ResultColumn::Expr { expr, .. } = col {
expr_has_window(expr)
} else {
false
}
})
}
/// Recursive check for window function calls in an expression.
/// True when `expr` is safe to evaluate during a plain rowid-order scan instead of the sorter path.
///
/// The forward-scan (`codegen_select_full_scan`) and sorter (`codegen_select_ordered_scan`) paths use
/// the SAME per-row WHERE evaluation, so a tree built only of the scalar nodes below is byte-identical
/// between them (the only difference being the elided sort). The two constructs that are NOT proven
/// equivalent here — and that would need vtab / subquery machinery — are declined: a `MATCH` operator
/// (FTS virtual table) and any subquery (`EXISTS`, scalar `Subquery`, `IN (SELECT ...)` / `IN table`).
/// This is a conservative WHITELIST: any variant not explicitly listed (or a declined child) returns
/// false, so an unrecognized future `Expr` shape keeps the safe sorter path. bd-nonagg-rowid-order-scan
/// (filtered ASC variant).
fn where_is_plain_scan_safe(expr: &Expr) -> bool {
match expr {
Expr::Literal(..)
| Expr::BoundOuterValue { .. }
| Expr::Column(..)
| Expr::Placeholder(..) => true,
Expr::BinaryOp { left, right, .. } => {
where_is_plain_scan_safe(left) && where_is_plain_scan_safe(right)
}
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => where_is_plain_scan_safe(inner),
Expr::Between {
expr: inner,
low,
high,
..
} => {
where_is_plain_scan_safe(inner)
&& where_is_plain_scan_safe(low)
&& where_is_plain_scan_safe(high)
}
Expr::In {
expr: inner, set, ..
} => {
where_is_plain_scan_safe(inner)
&& matches!(set, fsqlite_ast::InSet::List(items) if items.iter().all(where_is_plain_scan_safe))
}
Expr::Like {
expr: inner,
pattern,
escape,
op,
..
} => {
*op != fsqlite_ast::LikeOp::Match
&& where_is_plain_scan_safe(inner)
&& where_is_plain_scan_safe(pattern)
&& escape.as_deref().is_none_or(where_is_plain_scan_safe)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref().is_none_or(where_is_plain_scan_safe)
&& whens.iter().all(|(when_expr, then_expr)| {
where_is_plain_scan_safe(when_expr) && where_is_plain_scan_safe(then_expr)
})
&& else_expr.as_deref().is_none_or(where_is_plain_scan_safe)
}
Expr::FunctionCall {
over: None,
filter: None,
args,
..
} => {
matches!(args, fsqlite_ast::FunctionArgs::List(items) if items.iter().all(where_is_plain_scan_safe))
}
Expr::JsonAccess {
expr: inner, path, ..
} => where_is_plain_scan_safe(inner) && where_is_plain_scan_safe(path),
Expr::RowValue(items, _) => items.iter().all(where_is_plain_scan_safe),
// Declined: Exists, Subquery, Raise, IN (subquery/table), MATCH, window/filtered functions,
// and any unrecognized variant — all keep the safe sorter path.
_ => false,
}
}
fn expr_has_window(expr: &Expr) -> bool {
match expr {
Expr::FunctionCall { over: Some(_), .. } => true,
Expr::FunctionCall {
args,
order_by,
filter,
..
} => {
matches!(args, fsqlite_ast::FunctionArgs::List(items) if items.iter().any(expr_has_window))
|| order_by.iter().any(|term| expr_has_window(&term.expr))
|| filter.as_deref().is_some_and(expr_has_window)
}
Expr::BinaryOp { left, right, .. } => expr_has_window(left) || expr_has_window(right),
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => expr_has_window(inner),
Expr::Between {
expr: inner,
low,
high,
..
} => expr_has_window(inner) || expr_has_window(low) || expr_has_window(high),
Expr::In {
expr: inner, set, ..
} => {
expr_has_window(inner)
|| matches!(set, fsqlite_ast::InSet::List(items) if items.iter().any(expr_has_window))
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
expr_has_window(inner)
|| expr_has_window(pattern)
|| escape.as_deref().is_some_and(expr_has_window)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref().is_some_and(expr_has_window)
|| whens.iter().any(|(when_expr, then_expr)| {
expr_has_window(when_expr) || expr_has_window(then_expr)
})
|| else_expr.as_deref().is_some_and(expr_has_window)
}
Expr::JsonAccess {
expr: inner, path, ..
} => expr_has_window(inner) || expr_has_window(path),
Expr::RowValue(items, _) => items.iter().any(expr_has_window),
_ => false,
}
}
/// Check whether an expression contains an aggregate function call.
///
/// Built-in `max(x,y,...)` and `min(x,y,...)` calls with 2+ arguments are
/// scalar unless a custom aggregate registration replaces that exact arity.
fn is_aggregate_function_call(name: &str, args: &FunctionArgs) -> bool {
let arity = match args {
FunctionArgs::Star => 0,
FunctionArgs::List(items) => i32::try_from(items.len()).unwrap_or(i32::MAX),
};
if let Some(kind) = FUNCTION_REGISTRY.with(|registry| {
registry
.borrow()
.as_ref()
.and_then(|registry| registry.resolve_application_function(name, arity))
.map(|resolution| resolution.kind())
}) {
return kind.is_aggregate_callable();
}
if custom_aggregate_overrides_builtin(name, arity) {
return true;
}
if !is_aggregate_function(name) {
return false;
}
let lower = name.to_ascii_lowercase();
!((lower == "max" || lower == "min")
&& matches!(args, fsqlite_ast::FunctionArgs::List(items) if items.len() >= 2))
}
fn custom_aggregate_overrides_builtin(name: &str, arity: i32) -> bool {
if let Some(application_override) = FUNCTION_REGISTRY.with(|registry| {
registry.borrow().as_ref().map(|registry| {
registry
.resolve_application_function(name, arity)
.is_some_and(|resolution| resolution.kind().is_aggregate_callable())
})
}) {
return application_override;
}
let builtin_has_exact_arity = (name.eq_ignore_ascii_case("count") && matches!(arity, 0 | 1))
|| (name.eq_ignore_ascii_case("string_agg") && arity == 2)
|| (arity == 1
&& ["avg", "max", "median", "min", "sum", "total"]
.iter()
.any(|builtin| name.eq_ignore_ascii_case(builtin)))
|| (arity == 2
&& ["percentile", "percentile_cont", "percentile_disc"]
.iter()
.any(|builtin| name.eq_ignore_ascii_case(builtin)));
CUSTOM_AGG_KEYS.with(|keys| {
keys.borrow().iter().any(|(custom_name, custom_arity)| {
custom_name.eq_ignore_ascii_case(name)
&& custom_arity.accepts(arity)
&& (custom_arity.declared_args() != -1 || !builtin_has_exact_arity)
})
})
}
/// Whether an algebraic shortcut may rely on the named built-in aggregate's
/// exact semantics at this arity.
///
/// Generic aggregate lowering already routes an exact custom registration
/// through `AggStep`/`AggFinal`. Shortcuts such as `Count`, COUNT+SUM fusion,
/// and one-row MIN/MAX leaf seeks bypass part of that protocol and are valid
/// only when the connection has not replaced the corresponding built-in.
fn builtin_aggregate_semantics_available(name: &str, arity: i32) -> bool {
if FUNCTION_REGISTRY.with(|registry| {
registry
.borrow()
.as_ref()
.is_some_and(|registry| registry.resolve_application_function(name, arity).is_some())
}) {
return false;
}
!custom_aggregate_overrides_builtin(name, arity)
}
fn is_aggregate_expr(expr: &Expr) -> bool {
match expr {
Expr::FunctionCall { name, args, .. } if is_aggregate_function_call(name, args) => true,
Expr::BinaryOp { left, right, .. } => is_aggregate_expr(left) || is_aggregate_expr(right),
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => is_aggregate_expr(inner),
Expr::Between {
expr: inner,
low,
high,
..
} => is_aggregate_expr(inner) || is_aggregate_expr(low) || is_aggregate_expr(high),
Expr::In {
expr: inner, set, ..
} => {
if is_aggregate_expr(inner) {
return true;
}
match set {
fsqlite_ast::InSet::List(items) => items.iter().any(is_aggregate_expr),
_ => false,
}
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
is_aggregate_expr(inner)
|| is_aggregate_expr(pattern)
|| escape.as_deref().is_some_and(is_aggregate_expr)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
if operand.as_deref().is_some_and(is_aggregate_expr) {
return true;
}
if whens
.iter()
.any(|(cond, then_expr)| is_aggregate_expr(cond) || is_aggregate_expr(then_expr))
{
return true;
}
if else_expr.as_deref().is_some_and(is_aggregate_expr) {
return true;
}
false
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
matches!(args, FunctionArgs::List(args) if args.iter().any(is_aggregate_expr))
|| order_by.iter().any(|term| is_aggregate_expr(&term.expr))
|| filter.as_deref().is_some_and(is_aggregate_expr)
|| over.as_ref().is_some_and(window_spec_has_aggregate)
}
Expr::RowValue(items, _) => items.iter().any(is_aggregate_expr),
Expr::JsonAccess {
expr: inner, path, ..
} => is_aggregate_expr(inner) || is_aggregate_expr(path),
_ => false,
}
}
fn window_spec_has_aggregate(spec: &fsqlite_ast::WindowSpec) -> bool {
spec.partition_by.iter().any(is_aggregate_expr)
|| spec
.order_by
.iter()
.any(|term| is_aggregate_expr(&term.expr))
|| spec.frame.as_ref().is_some_and(|frame| {
frame_bound_has_aggregate(&frame.start)
|| frame.end.as_ref().is_some_and(frame_bound_has_aggregate)
})
}
fn frame_bound_has_aggregate(bound: &fsqlite_ast::FrameBound) -> bool {
match bound {
fsqlite_ast::FrameBound::Preceding(expr) | fsqlite_ast::FrameBound::Following(expr) => {
is_aggregate_expr(expr)
}
fsqlite_ast::FrameBound::UnboundedPreceding
| fsqlite_ast::FrameBound::CurrentRow
| fsqlite_ast::FrameBound::UnboundedFollowing => false,
}
}
/// Description of one aggregate column for codegen.
struct AggColumn {
/// Aggregate function name (lowercased).
name: String,
/// Number of arguments (0 for count(*), 1 for sum(col), etc.).
num_args: i32,
/// Column index of the argument (for single-arg aggregates), or `None` for count(*) or rowid.
arg_col_index: Option<usize>,
/// True if the argument is the INTEGER PRIMARY KEY (rowid) column.
arg_is_rowid: bool,
/// True if the aggregate uses DISTINCT (e.g. `COUNT(DISTINCT col)`).
distinct: bool,
/// Non-column expression argument (e.g. `SUM(a + b)`), evaluated via `emit_expr`.
/// `None` when arg is a simple column ref (use `arg_col_index` instead).
arg_expr: Option<Box<Expr>>,
/// Additional argument expressions beyond the first (e.g. separator for group_concat).
extra_args: Vec<Expr>,
/// FILTER clause expression, e.g. `COUNT(*) FILTER (WHERE x > 5)`.
/// When present, the AggStep is only executed if this evaluates to true.
filter: Option<Box<Expr>>,
/// Wrapper expression applied after AggFinal. Used when a scalar
/// function wraps an aggregate, e.g. `COALESCE(MAX(x), 0)`. The
/// placeholder `Expr::Literal(Literal::Null, _)` marks where the
/// aggregate result should be substituted.
wrapper_expr: Option<Box<Expr>>,
/// If true, this is a hidden aggregate that doesn't map to an output column.
/// Used for multi-aggregate expressions like `MAX(x) - MIN(x)`.
hidden: bool,
/// For multi-aggregate wrappers: the indices (into the agg_columns vec) of
/// all aggregates referenced by the wrapper expression. Placeholder columns
/// `__agg_0__`, `__agg_1__`, … map to these indices.
multi_agg_indices: Vec<usize>,
/// Bare (non-aggregate) expression in an aggregate query without GROUP BY.
/// SQLite allows `SELECT max(x), y FROM t` — `y` takes its value from the
/// last row scanned. When set, no AggStep/AggFinal is emitted; instead the
/// expression is evaluated on each scanned row and stored in the accumulator
/// register so it retains the value from the final row.
bare_expr: Option<Box<Expr>>,
/// Collation sequence for the aggregate argument column (e.g. "NOCASE").
/// When set and the aggregate is DISTINCT, the VDBE engine uses
/// collation-aware distinct key encoding.
collation: Option<String>,
}
/// Determine the effective collation for an aggregate argument expression.
/// Explicit COLLATE in the expression takes priority; otherwise, if the
/// argument resolved to a column index, inherit that column's declared
/// collation. Returns `None` for the default BINARY collation.
fn expr_collation_for_agg(
expr: &Expr,
col_idx: Option<usize>,
table: &TableSchema,
) -> Option<String> {
// Explicit COLLATE wrapper takes priority.
if let Expr::Collate { collation, .. } = expr {
if !collation.eq_ignore_ascii_case("BINARY") {
return Some(collation.clone());
}
return None;
}
if let Some(declared_collation) = bound_outer_declared_collation(expr) {
return (!declared_collation.eq_ignore_ascii_case("BINARY"))
.then(|| declared_collation.to_owned());
}
// Column reference: inherit from schema.
if let Some(idx) = col_idx
&& let Some(ci) = table.columns.get(idx)
&& let Some(coll) = &ci.collation
&& !coll.eq_ignore_ascii_case("BINARY")
{
return Some(coll.clone());
}
None
}
/// Build the P4 payload for an AggStep opcode, including collation if present.
fn agg_func_p4(name: &str, collation: Option<&String>) -> P4 {
if let Some(coll) = collation {
P4::FuncNameCollated(name.to_owned(), coll.clone())
} else {
P4::FuncName(name.to_owned())
}
}
struct GroupedInnerJoinCountSumPlan<'a> {
left_table: &'a TableSchema,
left_alias: Option<&'a str>,
right_table: &'a TableSchema,
right_alias: Option<&'a str>,
group_key_expr: &'a Expr,
sum_arg_expr: &'a Expr,
join_lookup: SingleJoinLookupPlan<'a>,
}
fn grouped_inner_join_count_sum_plan<'a>(
stmt: &'a SelectStatement,
from: &'a FromClause,
schema: &'a [TableSchema],
) -> Result<Option<GroupedInnerJoinCountSumPlan<'a>>, CodegenError> {
use fsqlite_ast::{FunctionArgs, JoinConstraint, JoinKind, TableOrSubquery};
if !builtin_aggregate_semantics_available("count", 0)
|| !builtin_aggregate_semantics_available("sum", 1)
{
return Ok(None);
}
let SelectCore::Select {
columns,
where_clause,
group_by,
having,
distinct,
..
} = &stmt.body.select
else {
return Ok(None);
};
if from.joins.len() != 1
|| columns.len() != 3
|| group_by.len() != 1
|| stmt.with.is_some()
|| !stmt.body.compounds.is_empty()
|| where_clause.is_some()
|| having.is_some()
|| !stmt.order_by.is_empty()
|| stmt.limit.is_some()
|| *distinct != Distinctness::All
|| has_window_columns(columns)
{
return Ok(None);
}
let TableOrSubquery::Table {
name: left_name,
alias: left_alias,
time_travel: None,
..
} = &from.source
else {
return Ok(None);
};
let join = &from.joins[0];
if join.join_type.kind != JoinKind::Inner || join.join_type.natural {
return Ok(None);
}
let TableOrSubquery::Table {
name: right_name,
alias: right_alias,
time_travel: None,
..
} = &join.table
else {
return Ok(None);
};
let Some(JoinConstraint::On(on_expr)) = join.constraint.as_ref() else {
return Ok(None);
};
let Expr::BinaryOp {
left: on_left,
op: BinaryOp::Eq,
right: on_right,
..
} = on_expr
else {
return Ok(None);
};
let (Expr::Column(on_left_col, _), Expr::Column(on_right_col, _)) = (&**on_left, &**on_right)
else {
return Ok(None);
};
if on_left_col.table.is_none() || on_right_col.table.is_none() {
return Ok(None);
}
let ResultColumn::Expr {
expr: group_key_expr,
..
} = &columns[0]
else {
return Ok(None);
};
let Expr::Column(group_key_col, _) = group_key_expr else {
return Ok(None);
};
let Expr::Column(group_by_col, _) = &group_by[0] else {
return Ok(None);
};
if group_key_col.table.is_none()
|| group_by_col.table.is_none()
|| !group_key_col
.column
.eq_ignore_ascii_case(&group_by_col.column)
|| !group_key_col
.table
.as_deref()
.zip(group_by_col.table.as_deref())
.is_some_and(|(left, right)| left.eq_ignore_ascii_case(right))
{
return Ok(None);
}
let ResultColumn::Expr {
expr: count_expr, ..
} = &columns[1]
else {
return Ok(None);
};
match count_expr {
Expr::FunctionCall {
name,
args: FunctionArgs::Star,
distinct: false,
order_by,
filter: None,
over: None,
..
} if name.eq_ignore_ascii_case("count") && order_by.is_empty() => {}
_ => return Ok(None),
}
let ResultColumn::Expr { expr: sum_expr, .. } = &columns[2] else {
return Ok(None);
};
let sum_arg_expr = match sum_expr {
Expr::FunctionCall {
name,
args: FunctionArgs::List(args),
distinct: false,
order_by,
filter: None,
over: None,
..
} if name.eq_ignore_ascii_case("sum") && order_by.is_empty() && args.len() == 1 => &args[0],
_ => return Ok(None),
};
let Expr::Column(sum_arg_col, _) = sum_arg_expr else {
return Ok(None);
};
if sum_arg_col.table.is_none() {
return Ok(None);
}
let left_table = find_table(schema, &left_name.name)?;
let right_table = find_table(schema, &right_name.name)?;
// The lookup lane fetches rows via IdxRowid + SeekRowid, which assumes
// rowid-table index format; WITHOUT ROWID index entries carry a PK suffix
// instead (bd-rjaff), so fall back to the generic join path.
if left_table.without_rowid || right_table.without_rowid {
return Ok(None);
}
let tables = [
(left_table, left_alias.as_deref()),
(right_table, right_alias.as_deref()),
];
let (group_cursor, group_col_idx) = resolve_join_column(
group_key_col.table.as_deref(),
&group_key_col.column,
&tables,
)?;
let group_col = &tables[group_cursor as usize].0.columns[group_col_idx];
if group_col
.collation
.as_deref()
.is_some_and(|collation| !collation.eq_ignore_ascii_case("BINARY"))
{
return Ok(None);
}
let _ = resolve_join_column(sum_arg_col.table.as_deref(), &sum_arg_col.column, &tables)?;
let Some(join_lookup) = resolve_single_join_lookup_plan(
left_table,
left_alias.as_deref(),
right_table,
right_alias.as_deref(),
JoinKind::Inner,
Some(on_expr),
) else {
return Ok(None);
};
Ok(Some(GroupedInnerJoinCountSumPlan {
left_table,
left_alias: left_alias.as_deref(),
right_table,
right_alias: right_alias.as_deref(),
group_key_expr,
sum_arg_expr,
join_lookup,
}))
}
#[allow(clippy::too_many_lines)]
fn codegen_grouped_inner_join_count_sum_select(
b: &mut ProgramBuilder,
plan: &GroupedInnerJoinCountSumPlan<'_>,
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
let out_regs = b.alloc_regs(3);
let left_cursor = 0_i32;
let right_cursor = 1_i32;
let index_cursor = if let SingleJoinLookupTarget::Index(index) = &plan.join_lookup.lookup_target
{
let cursor = 2_i32;
b.emit_op(
Opcode::OpenRead,
cursor,
index.root_page,
0,
P4::Index(index.name.clone()),
0,
);
Some(cursor)
} else {
None
};
let sorter_cursor = if index_cursor.is_some() { 3_i32 } else { 2_i32 };
let tables = [
(plan.left_table, plan.left_alias),
(plan.right_table, plan.right_alias),
];
b.emit_op(
Opcode::OpenRead,
left_cursor,
plan.left_table.root_page,
0,
P4::Table(plan.left_table.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
right_cursor,
plan.right_table.root_page,
0,
P4::Table(plan.right_table.name.clone()),
0,
);
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
2,
0,
P4::Str("+".to_owned()),
0,
);
let next_left_label = b.emit_label();
let scan_done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, left_cursor, 0, scan_done, P4::None, 0);
b.resolve_label(next_left_label);
match &plan.join_lookup.lookup_target {
SingleJoinLookupTarget::Rowid => {
let probe_reg = b.alloc_reg();
emit_join_probe_source(
b,
left_cursor,
plan.left_table,
plan.left_alias,
&plan.join_lookup.probe_source,
probe_reg,
);
let no_match = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, no_match, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
probe_reg,
no_match,
P4::None,
0,
);
emit_grouped_join_sorter_insert(
b,
sorter_cursor,
plan.group_key_expr,
plan.sum_arg_expr,
&tables,
ctx,
)?;
b.resolve_label(no_match);
}
SingleJoinLookupTarget::Index(_index) => {
let idx_cursor =
index_cursor.expect("grouped index lookup join must open index cursor");
let probe_base = b.alloc_regs(2);
let probe_reg = probe_base;
let min_rowid_reg = probe_base + 1;
let comparison_p4 = direct_lookup_index_comparison_p4(_index);
emit_join_probe_source(
b,
left_cursor,
plan.left_table,
plan.left_alias,
&plan.join_lookup.probe_source,
probe_reg,
);
let no_match = b.emit_label();
let duplicate_run_done = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, no_match, P4::None, 0);
b.emit_op(Opcode::Int64, 0, min_rowid_reg, 0, P4::Int64(i64::MIN), 0);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_base,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
no_match,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_reg,
idx_key_reg,
duplicate_run_done,
comparison_p4,
0,
);
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
let idx_advance = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
rowid_reg,
idx_advance,
P4::None,
0,
);
emit_grouped_join_sorter_insert(
b,
sorter_cursor,
plan.group_key_expr,
plan.sum_arg_expr,
&tables,
ctx,
)?;
b.resolve_label(idx_advance);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.resolve_label(duplicate_run_done);
b.resolve_label(no_match);
}
}
b.emit_jump_to_label(Opcode::Next, left_cursor, 0, next_left_label, P4::None, 0);
b.resolve_label(scan_done);
if let Some(idx_cursor) = index_cursor {
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, right_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, left_cursor, 0, 0, P4::None, 0);
let cur_key_reg = b.alloc_reg();
let prev_key_reg = b.alloc_reg();
let count_accum_reg = b.alloc_reg();
let sum_accum_reg = b.alloc_reg();
let first_flag_reg = b.alloc_reg();
b.emit_op(Opcode::Integer, 1, first_flag_reg, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, count_accum_reg, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, sum_accum_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
done_label,
P4::None,
0,
);
let sort_loop_body = b.current_addr();
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
b.emit_op(Opcode::Column, sorter_cursor, 0, cur_key_reg, P4::None, 0);
let first_row_label = b.emit_label();
b.emit_jump_to_label(
Opcode::IfPos,
first_flag_reg,
1,
first_row_label,
P4::None,
0,
);
let new_group_label = b.emit_label();
let same_group_label = b.emit_label();
b.emit_jump_to_label(
Opcode::Ne,
cur_key_reg,
prev_key_reg,
new_group_label,
P4::None,
0x80,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, same_group_label, P4::None, 0);
b.resolve_label(new_group_label);
b.emit_op(
Opcode::AggFinal,
count_accum_reg,
0,
0,
P4::FuncName("COUNT".to_owned()),
0,
);
b.emit_op(
Opcode::AggFinal,
sum_accum_reg,
1,
0,
P4::FuncName("SUM".to_owned()),
0,
);
b.emit_op(Opcode::Copy, prev_key_reg, out_regs, 0, P4::None, 0);
b.emit_op(Opcode::Copy, count_accum_reg, out_regs + 1, 0, P4::None, 0);
b.emit_op(Opcode::Copy, sum_accum_reg, out_regs + 2, 0, P4::None, 0);
b.emit_op(Opcode::ResultRow, out_regs, 3, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, count_accum_reg, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, sum_accum_reg, 0, P4::None, 0);
b.resolve_label(first_row_label);
b.resolve_label(same_group_label);
b.emit_op(Opcode::Copy, cur_key_reg, prev_key_reg, 0, P4::None, 0);
b.emit_op(
Opcode::AggStep,
0,
0,
count_accum_reg,
P4::FuncName("COUNT".to_owned()),
0,
);
let sum_arg_reg = b.alloc_reg();
b.emit_op(Opcode::Column, sorter_cursor, 1, sum_arg_reg, P4::None, 0);
b.emit_op(
Opcode::AggStep,
0,
sum_arg_reg,
sum_accum_reg,
P4::FuncName("SUM".to_owned()),
1,
);
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
sort_loop_body as i32,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::IfPos, first_flag_reg, 0, done_label, P4::None, 0);
b.emit_op(
Opcode::AggFinal,
count_accum_reg,
0,
0,
P4::FuncName("COUNT".to_owned()),
0,
);
b.emit_op(
Opcode::AggFinal,
sum_accum_reg,
1,
0,
P4::FuncName("SUM".to_owned()),
0,
);
b.emit_op(Opcode::Copy, prev_key_reg, out_regs, 0, P4::None, 0);
b.emit_op(Opcode::Copy, count_accum_reg, out_regs + 1, 0, P4::None, 0);
b.emit_op(Opcode::Copy, sum_accum_reg, out_regs + 2, 0, P4::None, 0);
b.emit_op(Opcode::ResultRow, out_regs, 3, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Generate VDBE bytecode for a standalone `VALUES` clause.
///
/// Pattern: `Init → Transaction → [for each row: eval exprs → ResultRow] → Halt`
#[derive(Clone)]
enum SingleJoinLookupTarget<'a> {
Rowid,
Index(&'a IndexSchema),
}
struct SingleJoinLookupPlan<'a> {
join_kind: fsqlite_ast::JoinKind,
probe_source: SortKeySource,
lookup_target: SingleJoinLookupTarget<'a>,
}
fn resolve_single_join_lookup_plan<'a>(
left_table: &'a TableSchema,
left_alias: Option<&'a str>,
right_table: &'a TableSchema,
right_alias: Option<&'a str>,
join_kind: fsqlite_ast::JoinKind,
on_expr: Option<&'a Expr>,
) -> Option<SingleJoinLookupPlan<'a>> {
// The lookup lane fetches rows via IdxRowid + SeekRowid, which assumes
// rowid-table index format; WITHOUT ROWID index entries carry a PK suffix
// instead (bd-rjaff), so fall back to the generic join path.
if left_table.without_rowid || right_table.without_rowid {
return None;
}
let on_expr = on_expr?;
let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} = on_expr
else {
return None;
};
let (probe_source, lookup_source) = if let (Some(left_probe), Some(right_lookup)) = (
resolve_column_ref(left, left_table, left_alias),
resolve_column_ref(right, right_table, right_alias),
) {
(left_probe, right_lookup)
} else if let (Some(left_lookup), Some(right_probe)) = (
resolve_column_ref(left, right_table, right_alias),
resolve_column_ref(right, left_table, left_alias),
) {
(right_probe, left_lookup)
} else {
return None;
};
let lookup_target = match lookup_source {
SortKeySource::Rowid => SingleJoinLookupTarget::Rowid,
SortKeySource::Column(col_idx) => {
let column_name = &right_table.columns.get(col_idx)?.name;
let comparison_tables = [(left_table, left_alias), (right_table, right_alias)];
let comparison_collation =
join_lookup_effective_collation(left, right, &comparison_tables);
// The single-join direct-lookup path only materializes the join key
// plus a rowid sentinel. Composite indexes need values for their
// trailing key terms, so reusing them here can skip valid rows.
let index = right_table
.single_column_index_for_column_with_collation(column_name, comparison_collation)?;
SingleJoinLookupTarget::Index(index)
}
SortKeySource::Expression(_) => return None,
};
Some(SingleJoinLookupPlan {
join_kind,
probe_source,
lookup_target,
})
}
fn emit_join_probe_source(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
source: &SortKeySource,
target_reg: i32,
) {
let scan = ScanCtx {
cursor,
table,
table_alias,
schema: None,
register_base: None,
secondaries: &[],
};
emit_resolved_column(b, source, cursor, target_reg, &scan);
}
fn emit_grouped_join_sorter_insert(
b: &mut ProgramBuilder,
sorter_cursor: i32,
group_key_expr: &Expr,
sum_arg_expr: &Expr,
tables: &[(&TableSchema, Option<&str>)],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let sorter_base = b.alloc_regs(2);
emit_join_expr(b, group_key_expr, sorter_base, tables, ctx)?;
emit_join_expr(b, sum_arg_expr, sorter_base + 1, tables, ctx)?;
let sorter_record = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
sorter_base,
2,
sorter_record,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sorter_cursor,
sorter_record,
0,
P4::None,
0,
);
Ok(())
}
fn join_lookup_effective_collation<'a>(
left: &'a Expr,
right: &'a Expr,
tables: &[(&'a TableSchema, Option<&'a str>)],
) -> Option<&'a str> {
join_comparison_collation_name(left, right, tables)
}
fn direct_lookup_index_collation_matches_join(
index: &IndexSchema,
comparison_collation: Option<&str>,
) -> bool {
collation_names_equivalent(comparison_collation, index.key_term_collation(0))
}
fn direct_lookup_index_comparison_p4(index: &IndexSchema) -> P4 {
index
.key_term_collation(0)
.filter(|collation| !collation.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |collation| P4::Collation(collation.to_owned()))
}
fn emit_join_output_or_sort(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
out_regs: i32,
tables: &[(&TableSchema, Option<&str>)],
ctx: &CodegenContext,
sorter: Option<(i32, i32, usize, i32)>,
order_by: &[OrderingTerm],
) -> Result<(), CodegenError> {
let out_col_count = resolve_join_output_count(columns, tables);
emit_join_result_columns(b, columns, out_regs, tables, ctx)?;
if let Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg)) = sorter {
for (i, term) in order_by.iter().enumerate() {
let sort_reg = sort_regs + i as i32;
emit_join_expr(b, &term.expr, sort_reg, tables, ctx)?;
}
for i in 0..out_col_count {
let src = out_regs + i as i32;
let dst = sort_regs + (sort_key_count + i) as i32;
b.emit_op(Opcode::SCopy, src, dst, 0, P4::None, 0);
}
b.emit_op(
Opcode::MakeRecord,
sort_regs,
(sort_key_count + out_col_count) as i32,
sort_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sort_cursor,
sort_record_reg,
0,
P4::None,
0,
);
} else {
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
}
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_single_join_lookup_select(
b: &mut ProgramBuilder,
stmt: &SelectStatement,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
left_table: &TableSchema,
left_alias: Option<&str>,
right_table: &TableSchema,
right_alias: Option<&str>,
plan: &SingleJoinLookupPlan<'_>,
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
let tables = [(left_table, left_alias), (right_table, right_alias)];
let out_col_count = resolve_join_output_count(columns, &tables);
let out_regs = b.alloc_regs(out_col_count as i32);
let left_cursor = 0_i32;
let right_cursor = 1_i32;
b.emit_op(
Opcode::OpenRead,
left_cursor,
left_table.root_page,
0,
P4::Table(left_table.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
right_cursor,
right_table.root_page,
0,
P4::Table(right_table.name.clone()),
0,
);
let index_cursor = if let SingleJoinLookupTarget::Index(index) = &plan.lookup_target {
let cursor = 2_i32;
b.emit_op(
Opcode::OpenRead,
cursor,
index.root_page,
0,
P4::Index(index.name.clone()),
0,
);
Some(cursor)
} else {
None
};
let sorter = if !stmt.order_by.is_empty() {
let sort_cursor = if index_cursor.is_some() { 3_i32 } else { 2_i32 };
let sort_key_count = stmt.order_by.len();
let total_sort_cols = sort_key_count + out_col_count;
let sort_regs = b.alloc_regs(total_sort_cols as i32);
let sort_record_reg = b.alloc_reg();
let sort_order = stmt
.order_by
.iter()
.map(|term| {
if term.direction == Some(fsqlite_ast::SortDirection::Desc) {
'-'
} else {
'+'
}
})
.collect::<String>();
b.emit_op(
Opcode::SorterOpen,
sort_cursor,
total_sort_cols as i32,
0,
P4::Affinity(sort_order),
0,
);
Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg))
} else {
None
};
let next_left_label = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, left_cursor, 0, done_label, P4::None, 0);
b.resolve_label(next_left_label);
let left_join_match_reg = if matches!(plan.join_kind, fsqlite_ast::JoinKind::Left) {
let reg = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
Some(reg)
} else {
None
};
match &plan.lookup_target {
SingleJoinLookupTarget::Rowid => {
let probe_reg = b.alloc_reg();
emit_join_probe_source(
b,
left_cursor,
left_table,
left_alias,
&plan.probe_source,
probe_reg,
);
let no_match = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, no_match, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
probe_reg,
no_match,
P4::None,
0,
);
if let Some(match_reg) = left_join_match_reg {
b.emit_op(Opcode::Integer, 1, match_reg, 0, P4::None, 0);
}
let matched_skip = b.emit_label();
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_reg();
emit_join_expr(b, where_expr, cond_reg, &tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, matched_skip, P4::None, 0);
}
emit_join_output_or_sort(b, columns, out_regs, &tables, ctx, sorter, &stmt.order_by)?;
b.resolve_label(matched_skip);
b.resolve_label(no_match);
}
SingleJoinLookupTarget::Index(_index) => {
let idx_cursor = index_cursor.expect("index lookup join must open index cursor");
let probe_base = b.alloc_regs(2);
let probe_reg = probe_base;
let min_rowid_reg = probe_base + 1;
let comparison_p4 = direct_lookup_index_comparison_p4(_index);
emit_join_probe_source(
b,
left_cursor,
left_table,
left_alias,
&plan.probe_source,
probe_reg,
);
let no_match = b.emit_label();
let duplicate_run_done = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, no_match, P4::None, 0);
b.emit_op(Opcode::Int64, 0, min_rowid_reg, 0, P4::Int64(i64::MIN), 0);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_base,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
no_match,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_reg,
idx_key_reg,
duplicate_run_done,
comparison_p4,
0,
);
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
let idx_advance = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
rowid_reg,
idx_advance,
P4::None,
0,
);
if let Some(match_reg) = left_join_match_reg {
b.emit_op(Opcode::Integer, 1, match_reg, 0, P4::None, 0);
}
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_reg();
emit_join_expr(b, where_expr, cond_reg, &tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, idx_advance, P4::None, 0);
}
emit_join_output_or_sort(b, columns, out_regs, &tables, ctx, sorter, &stmt.order_by)?;
b.resolve_label(idx_advance);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.resolve_label(duplicate_run_done);
b.resolve_label(no_match);
}
}
if let Some(match_reg) = left_join_match_reg {
let skip_left_join_null_row = b.emit_label();
b.emit_jump_to_label(
Opcode::IfPos,
match_reg,
0,
skip_left_join_null_row,
P4::None,
0,
);
b.emit_op(Opcode::NullRow, right_cursor, 0, 0, P4::None, 0);
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_reg();
emit_join_expr(b, where_expr, cond_reg, &tables, ctx)?;
b.emit_jump_to_label(
Opcode::IfNot,
cond_reg,
1,
skip_left_join_null_row,
P4::None,
0,
);
}
emit_join_output_or_sort(b, columns, out_regs, &tables, ctx, sorter, &stmt.order_by)?;
b.resolve_label(skip_left_join_null_row);
}
b.emit_jump_to_label(Opcode::Next, left_cursor, 0, next_left_label, P4::None, 0);
b.resolve_label(done_label);
if let Some((sort_cursor, sort_regs, sort_key_count, _sort_record_reg)) = sorter {
let sort_loop = b.emit_label();
let sort_done = b.emit_label();
b.emit_jump_to_label(Opcode::SorterSort, sort_cursor, 0, sort_done, P4::None, 0);
b.resolve_label(sort_loop);
b.emit_op(Opcode::SorterData, sort_cursor, sort_regs, 0, P4::None, 0);
for i in 0..out_col_count {
let dst = out_regs + i as i32;
b.emit_op(
Opcode::Column,
sort_cursor,
(sort_key_count + i) as i32,
dst,
P4::None,
0,
);
}
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::SorterNext, sort_cursor, 0, sort_loop, P4::None, 0);
b.resolve_label(sort_done);
b.emit_op(Opcode::Close, sort_cursor, 0, 0, P4::None, 0);
}
if let Some(idx_cursor) = index_cursor {
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, right_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Close, left_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Plan for a multi-table join where every right-side table can be reached
/// via a single-row lookup (rowid or indexed equality) against columns
/// already bound by an earlier table in the chain.
///
/// This is the generalisation of [`SingleJoinLookupPlan`] to N tables and
/// is the key fast-path for the kind of "fact table joined to multiple
/// dimension tables" query that the FTS rebuild in bd_zjisk issue #62
/// hits: scanning the single largest table once and doing O(1) index
/// seeks into every dimension table turns a 4-table Cartesian blowup
/// (reported at 600× the C SQLite baseline) into O(N) work.
struct MultiJoinLookupPlan<'a> {
steps: Vec<MultiJoinLookupStep<'a>>,
}
struct MultiJoinLookupStep<'a> {
join_kind: fsqlite_ast::JoinKind,
/// Which already-introduced table (0 = driver / left_table,
/// 1..=i = join_tables[0..i]) carries the probe expression.
probe_table_index: usize,
probe_source: SortKeySource,
lookup_target: SingleJoinLookupTarget<'a>,
}
/// Try to resolve every join in the chain to a single-row lookup against
/// an earlier table. Returns `None` if any join cannot be expressed as a
/// lookup — in that case the caller falls back to the existing nested-
/// loop `Rewind`/`Next` scan path.
///
/// Restrictions (conservatively applied):
/// - Every join must be INNER or LEFT.
/// - Every join's ON clause must be a single `expr_a = expr_b` BinaryOp,
/// where one side is a column of the new right table (the lookup
/// target) and the other is a column of an already-bound table
/// (the probe source).
/// - The lookup target must be the rowid (INTEGER PRIMARY KEY) or a
/// single-column index on the right table.
/// - **At most one LEFT JOIN is allowed, and it must be the final step
/// in the chain.** Anything else is refused so the codegen below can
/// emit a single trailing null-row block without having to re-run
/// later steps with a NullRow-poisoned probe cursor — which is
/// genuinely hard to get right in the general case and is not needed
/// for the bd_zjisk issue #62 repro (that query is INNER + INNER +
/// terminal LEFT). Non-terminal LEFT JOINs and chains with more
/// than one LEFT fall back to the scan path.
fn resolve_multi_join_lookup_plan<'a>(
left_table: &'a TableSchema,
left_alias: Option<&'a str>,
join_tables: &'a [(
&'a TableSchema,
Option<String>,
fsqlite_ast::JoinKind,
Option<&'a Expr>,
)],
) -> Option<MultiJoinLookupPlan<'a>> {
use fsqlite_ast::{BinaryOp, JoinKind};
// We need at least one join — the single-join path handles the 1
// case already with its own (sort-capable) codegen.
if join_tables.len() < 2 {
return None;
}
// The lookup lane fetches rows via IdxRowid + SeekRowid, which assumes
// rowid-table index format; WITHOUT ROWID index entries carry a PK suffix
// instead (bd-rjaff), so fall back to the generic join path.
if left_table.without_rowid || join_tables.iter().any(|(table, ..)| table.without_rowid) {
return None;
}
// Build the running table list so each step can resolve its probe
// against any already-bound table.
let mut tables: Vec<(&'a TableSchema, Option<&'a str>)> =
Vec::with_capacity(join_tables.len() + 1);
tables.push((left_table, left_alias));
let mut steps: Vec<MultiJoinLookupStep<'a>> = Vec::with_capacity(join_tables.len());
let last_index = join_tables.len() - 1;
for (step_idx, (right_table, right_alias_opt, join_kind, on_expr)) in
join_tables.iter().enumerate()
{
match join_kind {
JoinKind::Inner => {}
// Only the final step in the chain may be a LEFT JOIN — see
// the doc comment above. A LEFT miss jumps to a trailing
// null-row block that emits the row with NULLs for the
// left-joined table's columns and then runs the outer Next.
// Allowing a non-terminal LEFT JOIN would require re-running
// the rest of the chain from that null-row block against the
// remaining probe cursors, which the current code does not
// implement.
JoinKind::Left if step_idx == last_index => {}
_ => return None,
}
let on_expr = (*on_expr)?;
let Expr::BinaryOp {
left,
op: BinaryOp::Eq,
right,
..
} = on_expr
else {
return None;
};
let right_alias = right_alias_opt.as_deref();
let mut found: Option<(usize, SortKeySource, SortKeySource)> = None;
// Try each already-bound table as the probe source; first hit wins.
// Because LEFT JOINs are only allowed as the terminal step, no
// already-bound table in this loop can be a LEFT-joined table —
// we therefore do not need to track LEFT-joined state separately.
for (probe_idx, (probe_table, probe_alias)) in tables.iter().enumerate() {
if let (Some(left_probe), Some(right_lookup)) = (
resolve_column_ref(left, probe_table, *probe_alias),
resolve_column_ref(right, right_table, right_alias),
) {
found = Some((probe_idx, left_probe, right_lookup));
break;
}
if let (Some(left_lookup), Some(right_probe)) = (
resolve_column_ref(left, right_table, right_alias),
resolve_column_ref(right, probe_table, *probe_alias),
) {
found = Some((probe_idx, right_probe, left_lookup));
break;
}
}
let (probe_table_index, probe_source, lookup_source) = found?;
let comparison_tables = tables
.iter()
.copied()
.chain(std::iter::once((*right_table, right_alias)))
.collect::<Vec<_>>();
let comparison_collation = join_lookup_effective_collation(left, right, &comparison_tables);
let lookup_target = match lookup_source {
SortKeySource::Rowid => SingleJoinLookupTarget::Rowid,
SortKeySource::Column(col_idx) => {
let column_name = &right_table.columns.get(col_idx)?.name;
// The multi-join fast path is only correct when the probe key
// yields at most one row. Pick any direct-lookup single-column
// UNIQUE index on the join key instead of blindly taking the
// first leftmost-column index; otherwise a preceding non-unique
// sibling index would disable the fast path even when a safe
// unique lookup exists.
let index = right_table
.unique_single_column_index_for_column(column_name, comparison_collation)?;
SingleJoinLookupTarget::Index(index)
}
SortKeySource::Expression(_) => return None,
};
steps.push(MultiJoinLookupStep {
join_kind: *join_kind,
probe_table_index,
probe_source,
lookup_target,
});
// Record the new table in the accumulator before the next step
// resolves its probe.
tables.push((right_table, right_alias));
}
Some(MultiJoinLookupPlan { steps })
}
/// Emit bytecode for a multi-join query where every join is a single-row
/// lookup. The outer loop rewinds the driver once and, inside the loop
/// body, every right-side table is reached with a direct `SeekRowid`
/// (rowid lookup) or `SeekGE` on an index cursor — no nested Rewind/Next
/// loops. This is the O(N) fast path that avoids the Cartesian blowup
/// in the general `codegen_join_select` scan path.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_multi_join_lookup_select(
b: &mut ProgramBuilder,
stmt: &SelectStatement,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
left_table: &TableSchema,
left_alias: Option<&str>,
join_tables: &[(
&TableSchema,
Option<String>,
fsqlite_ast::JoinKind,
Option<&Expr>,
)],
plan: &MultiJoinLookupPlan<'_>,
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
use fsqlite_ast::JoinKind;
debug_assert_eq!(plan.steps.len(), join_tables.len());
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
// Build the table slice that emit_join_expr / emit_join_result_columns
// use for register resolution. Table index i ↔ cursor id i.
let all_tables: Vec<(&TableSchema, Option<&str>)> = std::iter::once((left_table, left_alias))
.chain(join_tables.iter().map(|(t, a, _, _)| (*t, a.as_deref())))
.collect();
let out_col_count = resolve_join_output_count(columns, &all_tables);
let out_regs = b.alloc_regs(out_col_count as i32);
// Open data cursors for every table. Cursor id = table index in
// all_tables (the convention emit_join_expr already relies on).
let left_cursor: i32 = 0;
b.emit_op(
Opcode::OpenRead,
left_cursor,
left_table.root_page,
0,
P4::Table(left_table.name.clone()),
0,
);
for (i, (right_table, _, _, _)) in join_tables.iter().enumerate() {
let cursor_id = (i + 1) as i32;
b.emit_op(
Opcode::OpenRead,
cursor_id,
right_table.root_page,
0,
P4::Table(right_table.name.clone()),
0,
);
}
// Open one extra index cursor per indexed step. We allocate them
// after the data cursors so that cursor IDs stay stable across
// steps even when only some of them use an index.
let mut index_cursors: Vec<Option<i32>> = Vec::with_capacity(plan.steps.len());
let data_cursor_count = (join_tables.len() + 1) as i32;
let mut next_aux_cursor = data_cursor_count;
for step in &plan.steps {
if let SingleJoinLookupTarget::Index(index) = &step.lookup_target {
let cursor = next_aux_cursor;
next_aux_cursor += 1;
b.emit_op(
Opcode::OpenRead,
cursor,
index.root_page,
0,
P4::Index(index.name.clone()),
0,
);
index_cursors.push(Some(cursor));
} else {
index_cursors.push(None);
}
}
// Optional sorter for ORDER BY. The sorter is the last cursor we
// allocate, so we do not bump `next_aux_cursor` after consuming it.
let sorter = if !stmt.order_by.is_empty() {
let sort_cursor = next_aux_cursor;
let sort_key_count = stmt.order_by.len();
let total_sort_cols = sort_key_count + out_col_count;
let sort_regs = b.alloc_regs(total_sort_cols as i32);
let sort_record_reg = b.alloc_reg();
let sort_order = stmt
.order_by
.iter()
.map(|term| {
if term.direction == Some(fsqlite_ast::SortDirection::Desc) {
'-'
} else {
'+'
}
})
.collect::<String>();
b.emit_op(
Opcode::SorterOpen,
sort_cursor,
total_sort_cols as i32,
0,
P4::Affinity(sort_order),
0,
);
Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg))
} else {
None
};
// Single outer rewind over the driver.
let next_left_label = b.emit_label();
let row_done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, left_cursor, 0, done_label, P4::None, 0);
b.resolve_label(next_left_label);
// Emit each lookup in order. For INNER joins a miss jumps to
// row_done_label (skip this outer row entirely). For the terminal
// LEFT JOIN (if any) a miss jumps to `left_null_label` — a block
// emitted past the main result row that NullRow-poisons the LEFT
// table's cursor and re-emits the row with NULLs.
//
// `resolve_multi_join_lookup_plan` guarantees that a LEFT join can
// only appear as the final step, so a miss never has to skip any
// remaining INNER probes.
let left_null_label = if plan
.steps
.last()
.is_some_and(|step| matches!(step.join_kind, JoinKind::Left))
{
Some(b.emit_label())
} else {
None
};
// The "emit one result row" tail is duplicated inline below — once
// on the all-lookups-landed path and once inside the trailing
// null-row block for the terminal LEFT JOIN miss. They MUST stay
// in lock-step when someone edits the sort-vs-direct branching.
// Extracting a helper is awkward because both call sites bind the
// `sorter` tuple fields, the `out_regs`/`out_col_count` locals,
// and emit bytecode through the same `ProgramBuilder` — leaving
// them inline keeps the control-flow structure visible next to
// the label resolution above and below.
for (i, step) in plan.steps.iter().enumerate() {
let right_cursor = (i + 1) as i32;
// Tables that have been bound by the time we emit step i.
// probe_table_index is always < i + 1 by construction.
let probe_tables = &all_tables[..=step.probe_table_index];
let probe_base = b.alloc_regs(1);
let probe_cursor = step.probe_table_index as i32;
let probe_scan = ScanCtx {
cursor: probe_cursor,
table: probe_tables[step.probe_table_index].0,
table_alias: probe_tables[step.probe_table_index].1,
schema: None,
register_base: None,
secondaries: &[],
};
emit_resolved_column(b, &step.probe_source, probe_cursor, probe_base, &probe_scan);
// A LEFT-join miss (only allowed on the last step) jumps to the
// trailing null-row block. Every other miss skips the whole row.
let miss_label = if matches!(step.join_kind, JoinKind::Left) {
left_null_label.expect("LEFT JOIN step must have allocated its null label")
} else {
row_done_label
};
// NULL probe → miss. (C SQLite treats NULL = X as "unknown" and
// therefore as not matching, so the row is skipped.)
b.emit_jump_to_label(Opcode::IsNull, probe_base, 0, miss_label, P4::None, 0);
match &step.lookup_target {
SingleJoinLookupTarget::Rowid => {
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
probe_base,
miss_label,
P4::None,
0,
);
}
SingleJoinLookupTarget::Index(index) => {
let idx_cursor =
index_cursors[i].expect("index lookup step must have an index cursor");
let comparison_p4 = direct_lookup_index_comparison_p4(index);
// Probe the secondary index for `probe = X`, then
// SeekRowid on the data cursor using the resulting
// rowid. The resolver guarantees non-unique indexes
// have been rejected, so the first matching key row
// is the only one.
//
// `probe_base` was the last alloc_regs call and
// `min_rowid_reg` is the very next alloc_reg, so they
// are guaranteed adjacent — MakeRecord can read the
// 2-register run [probe_base, probe_base+1] directly
// without copying into a separate block.
let min_rowid_reg = b.alloc_reg();
debug_assert_eq!(
min_rowid_reg,
probe_base + 1,
"probe_base and min_rowid_reg must be adjacent for MakeRecord"
);
b.emit_op(Opcode::Int64, 0, min_rowid_reg, 0, P4::Int64(i64::MIN), 0);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_base,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
miss_label,
P4::None,
0,
);
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_base,
idx_key_reg,
miss_label,
comparison_p4,
0,
);
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
right_cursor,
rowid_reg,
miss_label,
P4::None,
0,
);
}
}
}
// All lookups landed on a real row. Evaluate the optional WHERE
// clause against the full bound tuple.
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_regs(1);
emit_join_expr(b, where_expr, cond_reg, &all_tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, row_done_label, P4::None, 0);
}
// Emit the result tuple (either direct ResultRow or SorterInsert).
emit_join_result_columns(b, columns, out_regs, &all_tables, ctx)?;
if let Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg)) = sorter {
for (i, term) in stmt.order_by.iter().enumerate() {
let sort_reg = sort_regs + i as i32;
emit_join_expr(b, &term.expr, sort_reg, &all_tables, ctx)?;
}
for i in 0..out_col_count {
let src = out_regs + i as i32;
let dst = sort_regs + (sort_key_count + i) as i32;
b.emit_op(Opcode::SCopy, src, dst, 0, P4::None, 0);
}
b.emit_op(
Opcode::MakeRecord,
sort_regs,
(sort_key_count + out_col_count) as i32,
sort_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sort_cursor,
sort_record_reg,
0,
P4::None,
0,
);
} else {
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
}
// Jump past the trailing LEFT-JOIN null-row block (if any) to the
// outer Next. When there is no LEFT JOIN this is a harmless extra
// unconditional jump; keeping it makes the control-flow layout
// symmetric with the branch below.
b.emit_jump_to_label(Opcode::Goto, 0, 0, row_done_label, P4::None, 0);
// Trailing LEFT JOIN null-row block: the final step's lookup missed,
// so we emit `NullRow` on its cursor, re-evaluate the WHERE clause
// (which sees NULLs for the left-joined table's columns), and emit
// the row with those NULLs. `resolve_multi_join_lookup_plan`
// guarantees the LEFT join is the terminal step, so no later steps
// need to be re-run here.
if let Some(null_label) = left_null_label {
b.resolve_label(null_label);
let last_right_cursor = join_tables.len() as i32;
b.emit_op(Opcode::NullRow, last_right_cursor, 0, 0, P4::None, 0);
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_regs(1);
emit_join_expr(b, where_expr, cond_reg, &all_tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, row_done_label, P4::None, 0);
}
emit_join_result_columns(b, columns, out_regs, &all_tables, ctx)?;
if let Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg)) = sorter {
for (i, term) in stmt.order_by.iter().enumerate() {
let sort_reg = sort_regs + i as i32;
emit_join_expr(b, &term.expr, sort_reg, &all_tables, ctx)?;
}
for i in 0..out_col_count {
let src = out_regs + i as i32;
let dst = sort_regs + (sort_key_count + i) as i32;
b.emit_op(Opcode::SCopy, src, dst, 0, P4::None, 0);
}
b.emit_op(
Opcode::MakeRecord,
sort_regs,
(sort_key_count + out_col_count) as i32,
sort_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sort_cursor,
sort_record_reg,
0,
P4::None,
0,
);
} else {
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
}
}
b.resolve_label(row_done_label);
b.emit_jump_to_label(Opcode::Next, left_cursor, 0, next_left_label, P4::None, 0);
b.resolve_label(done_label);
if let Some((sort_cursor, sort_regs, sort_key_count, _sort_record_reg)) = sorter {
let sort_loop = b.emit_label();
let sort_done = b.emit_label();
b.emit_jump_to_label(Opcode::SorterSort, sort_cursor, 0, sort_done, P4::None, 0);
b.resolve_label(sort_loop);
b.emit_op(Opcode::SorterData, sort_cursor, sort_regs, 0, P4::None, 0);
for i in 0..out_col_count {
let dst = out_regs + i as i32;
b.emit_op(
Opcode::Column,
sort_cursor,
(sort_key_count + i) as i32,
dst,
P4::None,
0,
);
}
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::SorterNext, sort_cursor, 0, sort_loop, P4::None, 0);
b.resolve_label(sort_done);
b.emit_op(Opcode::Close, sort_cursor, 0, 0, P4::None, 0);
}
for cursor in index_cursors.iter().flatten() {
b.emit_op(Opcode::Close, *cursor, 0, 0, P4::None, 0);
}
for (i, _) in join_tables.iter().enumerate() {
let cursor = (i + 1) as i32;
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, left_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Codegen for SELECT ... FROM t1 JOIN t2 ON ... [WHERE ...] [ORDER BY ...]
///
/// Generates a nested-loop join: scan the outer table, for each row scan the
/// inner table, evaluate the ON condition + WHERE, emit ResultRow for matches.
/// This enables JOINs to execute through the VDBE storage-cursor path instead
/// of falling back to the connection-level MemDatabase interpreter.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_join_select(
b: &mut ProgramBuilder,
stmt: &SelectStatement,
from: &FromClause,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
schema: &[TableSchema],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
use fsqlite_ast::{JoinConstraint, JoinKind, TableOrSubquery};
// Extract left (driving) table.
let (left_name, left_alias) = match &from.source {
TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.as_deref()),
_ => {
return Err(CodegenError::Unsupported(
"non-table left source in JOIN".to_owned(),
));
}
};
let left_table = find_table(schema, left_name)?;
// Build list of join sources: (table_schema, alias, join_kind, on_expr)
let mut join_tables: Vec<(&TableSchema, Option<String>, JoinKind, Option<&Expr>)> =
Vec::with_capacity(from.joins.len());
for join in &from.joins {
let (right_name, right_alias) = match &join.table {
TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.clone()),
_ => {
return Err(CodegenError::Unsupported(
"non-table right source in JOIN".to_owned(),
));
}
};
let right_table = find_table(schema, right_name)?;
let on_expr = match &join.constraint {
Some(JoinConstraint::On(expr)) => Some(expr),
Some(JoinConstraint::Using(_)) => {
return Err(CodegenError::Unsupported(
"JOIN USING in codegen".to_owned(),
));
}
None => None,
};
join_tables.push((right_table, right_alias, join.join_type.kind, on_expr));
}
if join_tables.is_empty() {
return Err(CodegenError::Unsupported("empty join list".to_owned()));
}
if let [(right_table, right_alias, join_kind, on_expr)] = join_tables.as_slice()
&& let Some(plan) = resolve_single_join_lookup_plan(
left_table,
left_alias,
right_table,
right_alias.as_deref(),
*join_kind,
*on_expr,
)
{
return codegen_single_join_lookup_select(
b,
stmt,
columns,
where_clause,
left_table,
left_alias,
right_table,
right_alias.as_deref(),
&plan,
ctx,
);
}
// Multi-table fast path (issue #62): when every join in the chain
// can be expressed as a rowid / indexed-equality lookup against an
// already-bound table, avoid the Cartesian nested-loop scan below
// and emit a single outer loop with O(1) seeks into each dimension
// table. On the 4-table FTS rebuild reported in the bug, this
// replaces a 600×-slower full-materialization plan.
if join_tables.len() >= 2
&& let Some(plan) = resolve_multi_join_lookup_plan(left_table, left_alias, &join_tables)
{
return codegen_multi_join_lookup_select(
b,
stmt,
columns,
where_clause,
left_table,
left_alias,
&join_tables,
&plan,
ctx,
);
}
let supports_left_join = join_tables.len() == 1;
for (_, _, kind, _) in &join_tables {
let supported = match kind {
JoinKind::Inner | JoinKind::Cross => true,
JoinKind::Left if supports_left_join => true,
_ => false,
};
if !supported {
return Err(CodegenError::Unsupported(format!(
"{kind:?} JOIN not yet supported in VDBE codegen"
)));
}
}
let end_label = b.emit_label();
let done_label = b.emit_label();
// Init.
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
// Allocate output registers.
let all_tables: Vec<(&TableSchema, Option<&str>)> = std::iter::once((left_table, left_alias))
.chain(join_tables.iter().map(|(t, a, _, _)| (*t, a.as_deref())))
.collect();
let out_col_count = resolve_join_output_count(columns, &all_tables);
let out_regs = b.alloc_regs(out_col_count as i32);
// Open cursors for all tables.
let left_cursor = 0_i32;
b.emit_op(
Opcode::OpenRead,
left_cursor,
left_table.root_page,
0,
P4::Table(left_table.name.clone()),
0,
);
let mut right_cursors: Vec<i32> = Vec::with_capacity(join_tables.len());
for (i, (rt, _, _, _)) in join_tables.iter().enumerate() {
let cursor_id = (i + 1) as i32;
b.emit_op(
Opcode::OpenRead,
cursor_id,
rt.root_page,
0,
P4::Table(rt.name.clone()),
0,
);
right_cursors.push(cursor_id);
}
// Sorter for ORDER BY (if present).
let sorter = if !stmt.order_by.is_empty() {
let sort_cursor = (join_tables.len() + 1) as i32;
let sort_key_count = stmt.order_by.len();
let total_sort_cols = sort_key_count + out_col_count;
let sort_regs = b.alloc_regs(total_sort_cols as i32);
let sort_record_reg = b.alloc_reg();
let sort_order = stmt
.order_by
.iter()
.map(|term| {
if term.direction == Some(fsqlite_ast::SortDirection::Desc) {
'-'
} else {
'+'
}
})
.collect::<String>();
b.emit_op(
Opcode::SorterOpen,
sort_cursor,
total_sort_cols as i32,
0,
P4::Affinity(sort_order),
0,
);
Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg))
} else {
None
};
// Nested loop: Rewind left, for each row rewind right, check ON + WHERE, emit.
let next_left_label = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, left_cursor, 0, done_label, P4::None, 0);
b.resolve_label(next_left_label);
let left_join_match_reg = if supports_left_join && matches!(join_tables[0].2, JoinKind::Left) {
let reg = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
Some(reg)
} else {
None
};
// For each right table, emit a nested Rewind+Next loop.
let mut next_labels: Vec<Label> = Vec::with_capacity(right_cursors.len());
let mut done_right_labels: Vec<Label> = Vec::with_capacity(right_cursors.len());
for &rc in &right_cursors {
let next_right_label = b.emit_label();
let done_right_label = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, rc, 0, done_right_label, P4::None, 0);
b.resolve_label(next_right_label);
next_labels.push(next_right_label);
done_right_labels.push(done_right_label);
}
// Evaluate ON conditions — skip row if condition is false.
// When the condition fails, jump forward to the innermost Next opcode
// (not the loop start — that would re-check the same row forever).
let skip_label = b.emit_label();
for (_, _, _, on_expr) in &join_tables {
if let Some(expr) = on_expr {
let cond_reg = b.alloc_regs(1);
emit_join_expr(b, expr, cond_reg, &all_tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
}
}
if let Some(match_reg) = left_join_match_reg {
b.emit_op(Opcode::Integer, 1, match_reg, 0, P4::None, 0);
}
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_regs(1);
emit_join_expr(b, where_expr, cond_reg, &all_tables, ctx)?;
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
}
// Emit output columns.
emit_join_result_columns(b, columns, out_regs, &all_tables, ctx)?;
if let Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg)) = sorter {
// Copy sort keys then output columns into sorter registers.
for (i, term) in stmt.order_by.iter().enumerate() {
let sort_reg = sort_regs + i as i32;
emit_join_expr(b, &term.expr, sort_reg, &all_tables, ctx)?;
}
for i in 0..out_col_count {
let src = out_regs + i as i32;
let dst = sort_regs + (sort_key_count + i) as i32;
b.emit_op(Opcode::SCopy, src, dst, 0, P4::None, 0);
}
b.emit_op(
Opcode::MakeRecord,
sort_regs,
(sort_key_count + out_col_count) as i32,
sort_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sort_cursor,
sort_record_reg,
0,
P4::None,
0,
);
} else {
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
}
// Close nested loops (inner to outer).
// Resolve skip_label here so failed ON/WHERE conditions jump to the
// innermost Next (advancing the cursor) rather than the loop body start.
b.resolve_label(skip_label);
for (i, &rc) in right_cursors.iter().enumerate().rev() {
b.emit_jump_to_label(Opcode::Next, rc, 0, next_labels[i], P4::None, 0);
b.resolve_label(done_right_labels[i]);
}
if let Some(match_reg) = left_join_match_reg {
let skip_left_join_null_row = b.emit_label();
b.emit_jump_to_label(
Opcode::IfPos,
match_reg,
0,
skip_left_join_null_row,
P4::None,
0,
);
b.emit_op(Opcode::NullRow, right_cursors[0], 0, 0, P4::None, 0);
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_regs(1);
emit_join_expr(b, where_expr, cond_reg, &all_tables, ctx)?;
b.emit_jump_to_label(
Opcode::IfNot,
cond_reg,
1,
skip_left_join_null_row,
P4::None,
0,
);
}
emit_join_result_columns(b, columns, out_regs, &all_tables, ctx)?;
if let Some((sort_cursor, sort_regs, sort_key_count, sort_record_reg)) = sorter {
for (i, term) in stmt.order_by.iter().enumerate() {
let sort_reg = sort_regs + i as i32;
emit_join_expr(b, &term.expr, sort_reg, &all_tables, ctx)?;
}
for i in 0..out_col_count {
let src = out_regs + i as i32;
let dst = sort_regs + (sort_key_count + i) as i32;
b.emit_op(Opcode::SCopy, src, dst, 0, P4::None, 0);
}
b.emit_op(
Opcode::MakeRecord,
sort_regs,
(sort_key_count + out_col_count) as i32,
sort_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sort_cursor,
sort_record_reg,
0,
P4::None,
0,
);
} else {
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
}
b.resolve_label(skip_left_join_null_row);
}
b.emit_jump_to_label(Opcode::Next, left_cursor, 0, next_left_label, P4::None, 0);
b.resolve_label(done_label);
// If sorting, emit sorter output.
if let Some((sort_cursor, sort_regs, sort_key_count, _sort_record_reg)) = sorter {
let sort_loop = b.emit_label();
let sort_done = b.emit_label();
b.emit_jump_to_label(Opcode::SorterSort, sort_cursor, 0, sort_done, P4::None, 0);
b.resolve_label(sort_loop);
b.emit_op(Opcode::SorterData, sort_cursor, sort_regs, 0, P4::None, 0);
// Extract output columns from sorter data.
for i in 0..out_col_count {
let dst = out_regs + i as i32;
b.emit_op(
Opcode::Column,
sort_cursor,
(sort_key_count + i) as i32,
dst,
P4::None,
0,
);
}
b.emit_op(
Opcode::ResultRow,
out_regs,
out_col_count as i32,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::SorterNext, sort_cursor, 0, sort_loop, P4::None, 0);
b.resolve_label(sort_done);
}
// LIMIT/OFFSET (simple version).
// (Full LIMIT support is handled by the caller's post-processing.)
// Close cursors.
for &rc in &right_cursors {
b.emit_op(Opcode::Close, rc, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, left_cursor, 0, 0, P4::None, 0);
// Halt.
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump — Init will see this points past the
// program end and fall through to Transaction (the standard pattern
// used by all other codegen paths).
b.resolve_label(end_label);
Ok(())
}
/// Count output columns for a JOIN query.
fn resolve_join_output_count(
columns: &[ResultColumn],
tables: &[(&TableSchema, Option<&str>)],
) -> usize {
columns
.iter()
.map(|col| match col {
ResultColumn::Star => tables.iter().map(|(t, _)| t.columns.len()).sum(),
ResultColumn::TableStar(name) => tables
.iter()
.find(|(t, alias)| join_qualifier_matches(Some(&name.name), t, *alias))
.map_or(0, |(t, _)| t.columns.len()),
ResultColumn::Expr { .. } => 1,
})
.sum()
}
/// Emit a single expression for a JOIN context (multi-table column resolution).
///
/// This is a simplified expression emitter that handles the common cases:
/// column references (qualified and unqualified), literals, and simple
/// binary comparisons. Complex expressions fall back to unsupported error.
#[allow(clippy::too_many_lines)]
fn emit_join_expr(
b: &mut ProgramBuilder,
expr: &Expr,
target: i32,
tables: &[(&TableSchema, Option<&str>)],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
match expr {
Expr::Column(col_ref, _) => {
match resolve_join_column_ref(col_ref.table.as_deref(), &col_ref.column, tables)? {
JoinColumnResolution::HiddenRowid(cursor) => {
b.emit_op(Opcode::Rowid, cursor, target, 0, P4::None, 0);
}
JoinColumnResolution::Column(cursor, col_idx) => {
let (table, table_alias) = tables[cursor as usize];
if table.columns[col_idx].is_ipk {
// bd-ghiey: an INTEGER PRIMARY KEY is stored as the
// b-tree rowid, not in the record, so read it via Rowid
// (mirroring single-table `emit_table_column_read`). The
// join path's plain `Column` read relies on the engine's
// `rowid_alias_col_by_root_page` map to substitute the
// rowid, but that map is keyed by pre-routing root pages
// built from `self.schema` — it misses TEMP tables (whose
// roots are routed to the temp database) and shadowed main
// tables (parked out of `self.schema`), so their IPK
// projected as NULL through a join. Emitting `Rowid`
// directly is what stock does and needs no runtime map.
b.emit_op(Opcode::Rowid, cursor, target, 0, P4::None, 0);
} else if virtual_generated_column_expr(&table.columns[col_idx]).is_some() {
// GH#227 (bd-gh-virtual-generated-columns-5e0u1): a
// VIRTUAL generated column is not materialized in the
// record — the slot holds a NULL placeholder. Reading it
// raw here made a JOIN predicate/projection on the column
// compare NULL and drop every row. Route through the
// canonical table-column reader so the generating
// expression is computed (and affinity-coerced) on read,
// exactly as single-table projection does.
//
// bd-3radn H1: in an OUTER JOIN the cursor can be on a
// NULL-extended (unmatched) row. Computing the generating
// expression there yields a bogus non-NULL value
// (e.g. COALESCE(base, 99) => 99 instead of NULL) and
// breaks anti-joins (`WHERE g IS NULL`). Guard with
// IfNullRow (C SQLite's OP_IfNullRow): a null-row leaves
// the column NULL and skips the computation. A REAL row
// whose base value happens to be NULL is NOT a null-row,
// so its generating expression is still evaluated.
let skip = b.emit_label();
b.emit_jump_to_label(Opcode::IfNullRow, cursor, target, skip, P4::None, 0);
emit_table_column_read(
b,
cursor,
table,
table_alias,
None,
col_idx,
target,
);
b.resolve_label(skip);
} else {
b.emit_op(Opcode::Column, cursor, col_idx as i32, target, P4::None, 0);
}
}
}
Ok(())
}
Expr::Literal(lit, _) => {
match lit {
Literal::Integer(n) => {
if let Ok(small) = i32::try_from(*n) {
b.emit_op(Opcode::Integer, small, target, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Int64, 0, target, 0, P4::Int64(*n), 0);
}
}
Literal::Float(f) => {
b.emit_op(Opcode::Real, 0, target, 0, P4::Real(*f), 0);
}
Literal::String(s) => {
b.emit_op(Opcode::String8, 0, target, 0, P4::Str(s.clone()), 0);
}
Literal::Blob(bytes) => {
b.emit_op(
Opcode::Blob,
bytes.len() as i32,
target,
0,
P4::Blob(bytes.clone()),
0,
);
}
Literal::Null => {
b.emit_op(Opcode::Null, 0, target, 0, P4::None, 0);
}
Literal::True => {
b.emit_op(Opcode::Integer, 1, target, 0, P4::None, 0);
}
Literal::False => {
b.emit_op(Opcode::Integer, 0, target, 0, P4::None, 0);
}
Literal::CurrentTime | Literal::CurrentDate | Literal::CurrentTimestamp => {
if let Some(text) = current_time_literal_text(lit) {
b.emit_op(Opcode::String8, 0, target, 0, P4::Str(text), 0);
}
}
}
Ok(())
}
Expr::BoundOuterValue { value, .. } => {
emit_sqlite_value(b, value, target);
Ok(())
}
Expr::BinaryOp {
left, op, right, ..
} => {
use fsqlite_ast::BinaryOp;
let left_reg = b.alloc_regs(1);
let right_reg = b.alloc_regs(1);
emit_join_expr(b, left, left_reg, tables, _ctx)?;
emit_join_expr(b, right, right_reg, tables, _ctx)?;
let comparison_collation = join_comparison_collation_name(left, right, tables)
.map_or(P4::None, |coll| P4::Collation(coll.to_owned()));
let comparison_p5 = 0x20 | join_comparison_affinity_p5(left, right, tables);
match op {
BinaryOp::Eq => {
// Use Eq with STOREP2 to store result in target.
b.emit_op(
Opcode::Eq,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Ne => {
b.emit_op(
Opcode::Ne,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Lt => {
b.emit_op(
Opcode::Lt,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Le => {
b.emit_op(
Opcode::Le,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Gt => {
b.emit_op(
Opcode::Gt,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Ge => {
b.emit_op(
Opcode::Ge,
right_reg,
target,
left_reg,
comparison_collation,
comparison_p5,
);
}
BinaryOp::Add => {
b.emit_op(Opcode::Add, right_reg, left_reg, target, P4::None, 0);
}
BinaryOp::Subtract => {
b.emit_op(Opcode::Subtract, right_reg, left_reg, target, P4::None, 0);
}
BinaryOp::Multiply => {
b.emit_op(Opcode::Multiply, right_reg, left_reg, target, P4::None, 0);
}
BinaryOp::And => {
b.emit_op(Opcode::And, left_reg, right_reg, target, P4::None, 0);
}
BinaryOp::Or => {
b.emit_op(Opcode::Or, left_reg, right_reg, target, P4::None, 0);
}
BinaryOp::Divide
| BinaryOp::Modulo
| BinaryOp::Concat
| BinaryOp::BitAnd
| BinaryOp::BitOr
| BinaryOp::ShiftLeft
| BinaryOp::ShiftRight => {
b.emit_op(
binary_op_to_opcode(*op),
right_reg,
left_reg,
target,
P4::None,
0,
);
}
BinaryOp::Is | BinaryOp::IsNot => {
return Err(CodegenError::Unsupported(format!(
"binary op {op:?} in JOIN codegen"
)));
}
}
Ok(())
}
Expr::IsNull {
expr: inner, not, ..
} => {
let inner_reg = b.alloc_regs(1);
emit_join_expr(b, inner, inner_reg, tables, _ctx)?;
let skip = b.emit_label();
// IS NULL: result is 1 when inner is null, 0 otherwise.
// IS NOT NULL: result is 0 when inner is null, 1 otherwise.
let (val_if_null, val_if_not_null) = if *not { (0, 1) } else { (1, 0) };
b.emit_op(Opcode::Integer, val_if_null, target, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, inner_reg, 0, skip, P4::None, 0);
b.emit_op(Opcode::Integer, val_if_not_null, target, 0, P4::None, 0);
b.resolve_label(skip);
Ok(())
}
_ => Err(CodegenError::Unsupported(format!(
"expression {expr:?} in JOIN codegen"
))),
}
}
enum JoinColumnResolution {
HiddenRowid(i32),
Column(i32, usize),
}
fn ambiguous_join_column(name: &str) -> CodegenError {
CodegenError::AmbiguousColumn(name.to_owned())
}
fn join_qualified_column_name(qualifier: Option<&str>, name: &str) -> String {
qualifier.map_or_else(|| name.to_owned(), |q| format!("{q}.{name}"))
}
fn join_qualifier_matches(
qualifier: Option<&str>,
table: &TableSchema,
alias: Option<&str>,
) -> bool {
qualifier.is_none_or(|q| {
alias.map_or_else(
|| table.name.eq_ignore_ascii_case(q),
|a| a.eq_ignore_ascii_case(q),
)
})
}
fn resolve_join_column_ref(
qualifier: Option<&str>,
name: &str,
tables: &[(&TableSchema, Option<&str>)],
) -> Result<JoinColumnResolution, CodegenError> {
let name_lower = name.to_ascii_lowercase();
let mut found = None;
for (cursor_idx, (table, alias)) in tables.iter().enumerate() {
if !join_qualifier_matches(qualifier, table, *alias) {
continue;
}
for (col_idx, col) in table.columns.iter().enumerate() {
if col.name.eq_ignore_ascii_case(&name_lower) {
let resolution = JoinColumnResolution::Column(cursor_idx as i32, col_idx);
if found.replace(resolution).is_some() {
return Err(ambiguous_join_column(&join_qualified_column_name(
qualifier, name,
)));
}
}
}
if table.resolves_to_hidden_rowid(name) {
let resolution = JoinColumnResolution::HiddenRowid(cursor_idx as i32);
if found.replace(resolution).is_some() {
return Err(ambiguous_join_column(&join_qualified_column_name(
qualifier, name,
)));
}
}
}
found.ok_or_else(|| {
qualifier.map_or_else(
|| CodegenError::ColumnNotFound {
table: String::new(),
column: name.to_owned(),
},
|qualifier| qualified_column_not_found(qualifier, name),
)
})
}
/// Resolve a column reference to (cursor_id, column_index) across multiple tables.
fn resolve_join_column(
qualifier: Option<&str>,
name: &str,
tables: &[(&TableSchema, Option<&str>)],
) -> Result<(i32, usize), CodegenError> {
let name_lower = name.to_ascii_lowercase();
let mut found = None;
for (cursor_idx, (table, alias)) in tables.iter().enumerate() {
// Check if qualifier matches table name or alias.
if !join_qualifier_matches(qualifier, table, *alias) {
continue;
}
for (col_idx, col) in table.columns.iter().enumerate() {
if col.name.eq_ignore_ascii_case(&name_lower) {
let resolution = (cursor_idx as i32, col_idx);
if found.replace(resolution).is_some() {
return Err(ambiguous_join_column(&join_qualified_column_name(
qualifier, name,
)));
}
}
}
}
found.ok_or_else(|| {
qualifier.map_or_else(
|| CodegenError::ColumnNotFound {
table: String::new(),
column: name.to_owned(),
},
|qualifier| qualified_column_not_found(qualifier, name),
)
})
}
/// Emit result columns for a JOIN query.
fn emit_join_result_columns(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
out_regs: i32,
tables: &[(&TableSchema, Option<&str>)],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let mut reg_offset = 0;
for col in columns {
match col {
ResultColumn::Star => {
// Emit all columns from all tables.
for (cursor_idx, (table, _)) in tables.iter().enumerate() {
for col_idx in 0..table.columns.len() {
let dst = out_regs + reg_offset;
// bd-ghiey: an INTEGER PRIMARY KEY lives in the b-tree
// rowid, not the record — read it via Rowid (see the
// Expr-path note in `emit_join_expr`), so a `SELECT *`
// over a TEMP/shadowed table in a join projects the id
// instead of NULL.
if table.columns[col_idx].is_ipk {
b.emit_op(Opcode::Rowid, cursor_idx as i32, dst, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
cursor_idx as i32,
col_idx as i32,
dst,
P4::None,
0,
);
}
reg_offset += 1;
}
}
}
ResultColumn::TableStar(table_name) => {
let mut matched = false;
for (cursor_idx, (table, alias)) in tables.iter().enumerate() {
if join_qualifier_matches(Some(&table_name.name), table, *alias) {
matched = true;
for col_idx in 0..table.columns.len() {
let dst = out_regs + reg_offset;
if table.columns[col_idx].is_ipk {
b.emit_op(Opcode::Rowid, cursor_idx as i32, dst, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
cursor_idx as i32,
col_idx as i32,
dst,
P4::None,
0,
);
}
reg_offset += 1;
}
break;
}
}
if !matched {
return Err(CodegenError::TableNotFound(table_name.to_string()));
}
}
ResultColumn::Expr { expr, .. } => {
let dst = out_regs + reg_offset;
emit_join_expr(b, expr, dst, tables, ctx)?;
reg_offset += 1;
}
}
}
Ok(())
}
///
/// Handles `VALUES (1, 'a'), (2, 'b')` etc.
fn codegen_values_select(b: &mut ProgramBuilder, rows: &[Vec<Expr>]) {
if rows.is_empty() {
let end_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
return;
}
let end_label = b.emit_label();
// Init: jump to end (standard SQLite pattern).
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (read-only, p2=0).
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
// Determine column count from the first row.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let num_cols = rows[0].len() as i32;
let out_regs = b.alloc_regs(num_cols);
// Emit each row: evaluate expressions, then ResultRow.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for row in rows {
for (i, expr) in row.iter().enumerate() {
let reg = out_regs + i as i32;
emit_expr(b, expr, reg, None);
}
b.emit_op(Opcode::ResultRow, out_regs, num_cols, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
}
/// Generate VDBE bytecode for SELECT without FROM clause.
///
/// Pattern: `Init → Transaction → [eval exprs] → ResultRow → Halt`
///
/// Handles `SELECT 1`, `SELECT 1+2, 'abc'`, `SELECT abs(-5)`, etc.
/// If a WHERE clause is present and evaluates to false/NULL, no row is emitted.
fn codegen_select_without_from(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
) {
let end_label = b.emit_label();
let halt_label = b.emit_label();
// Init: jump to end (standard SQLite pattern).
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (read-only, p2=0).
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
// WHERE clause: if present and false/NULL, skip to Halt.
if let Some(where_expr) = where_clause {
let cond_reg = b.alloc_temp();
emit_expr(b, where_expr, cond_reg, None);
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, halt_label, P4::None, 0);
b.free_temp(cond_reg);
}
// Evaluate each result column expression into consecutive output registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let num_cols = columns.len() as i32;
let out_regs = b.alloc_regs(num_cols);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, col) in columns.iter().enumerate() {
let reg = out_regs + i as i32;
match col {
ResultColumn::Expr { expr, .. } => {
emit_expr(b, expr, reg, None);
}
ResultColumn::Star | ResultColumn::TableStar(_) => {
// No table → Star has no meaning; emit NULL.
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
}
}
b.emit_op(Opcode::ResultRow, out_regs, num_cols, 0, P4::None, 0);
b.resolve_label(halt_label);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
}
/// Which end of the table B-tree a `MIN`/`MAX(rowid)` extremum lives at.
#[derive(Clone, Copy)]
enum RowidSeekEnd {
/// `MIN(rowid)` → leftmost leaf (first row).
First,
/// `MAX(rowid)` → rightmost leaf (last row).
Last,
}
/// Detect the `MAX(rowid)` / `MIN(rowid)` leaf-seek special case.
///
/// Fires only when the parsed aggregate columns reduce to *exactly one* output
/// aggregate over the rowid / INTEGER PRIMARY KEY, with no other aggregate or
/// bare columns, no `DISTINCT`, and no `FILTER`. An optional scalar wrapper
/// (e.g. `COALESCE(MAX(id), 0)`) is allowed because it is applied unchanged
/// after finalization. Any additional output column (a second aggregate or a
/// bare `SELECT max(id), other` column) disqualifies the fast path so its
/// semantics — bare columns take the value of the *last scanned row* — are
/// never altered. Returns the end of the B-tree to seek when applicable.
fn minmax_rowid_seek_plan(agg_columns: &[AggColumn]) -> Option<RowidSeekEnd> {
let [agg] = agg_columns else {
return None;
};
if !agg.arg_is_rowid
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
match agg.name.as_str() {
"MIN" => Some(RowidSeekEnd::First),
"MAX" => Some(RowidSeekEnd::Last),
_ => None,
}
}
/// A single `MIN(col)` / `MAX(col)` over a secondary-indexed column, resolvable by one index seek.
struct MinMaxIndexSeek {
/// `true` = `MAX`; `false` = `MIN`.
is_max: bool,
index_name: String,
index_root: i32,
/// `true` when the column's leading index term is DESC (extremum ends and NULL region flip).
descending: bool,
}
/// Find an index whose extremum-of-`col_name` sits at one end of the index (index column 0), returning
/// it with its leading-term direction (`true` = DESC).
///
/// A single-column ASC collation-matched index is preferred (allows NOCASE); otherwise a BINARY-only
/// fallback accepts a composite index whose leading term is `col_name` (ASC or DESC) or a single-column
/// DESC index. BINARY-only there avoids the collation-tie representative ambiguity a non-BINARY leading
/// term would introduce among the extremum's duplicate run. Shared by the single- and pair-seek plans.
fn find_minmax_leading_index<'t>(
table: &'t TableSchema,
col_name: &str,
collation: Option<&str>,
) -> Option<(&'t IndexSchema, bool)> {
table
.single_column_index_for_column_with_collation(col_name, collation)
.map(|idx| (idx, false))
.or_else(|| {
if collation.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY")) || table.without_rowid {
return None;
}
table.indexes.iter().find_map(|idx| {
let leads = idx.supports_direct_column_lookup()
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
&& idx
.key_term_collation(0)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"));
// Composite leading (any direction), or a single-column DESC index (single-column ASC
// is handled above).
let usable = leads && (idx.key_term_count() >= 2 || idx.key_term_descending(0));
usable.then(|| (idx, idx.key_term_descending(0)))
})
})
}
/// The single-column ASC BINARY-collation index a `SELECT COUNT(DISTINCT col) FROM t` (no
/// WHERE/HAVING/GROUP BY) can walk in key order — counting non-NULL key-changes — instead of a full
/// scan that maintains an ephemeral dedup B-tree. The index groups equal values adjacently under
/// BINARY, so adjacent-key counting is byte-identical to the full-scan DISTINCT path, and COUNT(*) needs
/// no table read (covering).
struct CountDistinctIndexWalk {
index_name: String,
index_root: i32,
}
/// Detect `SELECT COUNT(DISTINCT col) FROM t` served by a single covering index walk. Gated tight so
/// the adjacent-key count is provably byte-identical: exactly one aggregate (no bare output column), a
/// plain single-column argument (not `*`/rowid/expr), no FILTER/wrapper/DISTINCT-collation other than
/// BINARY, and a single-column ASC BINARY index on the column. A non-BINARY (e.g. NOCASE) column would
/// group case-folded values that the BINARY index/compare splits, so it declines to the scan. Searches
/// all indexes so a composite `(col, …)` index declared first does not shadow a usable single-column one
/// (bd-agg-range-shadowed-index). bd-count-distinct-index-walk.
fn count_distinct_index_walk_plan(
agg_columns: &[AggColumn],
columns: &[ResultColumn],
table: &TableSchema,
) -> Option<CountDistinctIndexWalk> {
let [agg] = agg_columns else {
return None;
};
if columns.len() != 1
|| !agg.name.eq_ignore_ascii_case("count")
|| !builtin_aggregate_semantics_available(&agg.name, 1)
|| !agg.distinct
|| agg.filter.is_some()
|| agg.wrapper_expr.is_some()
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| agg.arg_is_rowid
|| agg.hidden
|| !agg.extra_args.is_empty()
{
return None;
}
// BINARY only: the index's BINARY key order must equal the DISTINCT comparison so the adjacent-key
// (no-affinity) equality matches C SQLite's grouping.
if agg
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
let col_idx = agg.arg_col_index?;
let column = table.columns.get(col_idx)?;
// A COMPOSITE index whose LEADING key term is this column (ASC BINARY)
// qualifies: the walk reads only index column 0 and counts leading-value
// changes, so trailing terms are irrelevant. `supports_direct_column_lookup`
// still excludes partial (WHERE) and expression indexes (full plain-column
// coverage). Prefer the narrowest qualifying index (fewest entries to walk).
let idx = table
.indexes
.iter()
.filter(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() >= 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&column.name))
&& idx
.key_term_collation(0)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
})
.min_by_key(|idx| idx.key_term_count())?;
Some(CountDistinctIndexWalk {
index_name: idx.name.clone(),
index_root: idx.root_page,
})
}
/// A WHERE that is exactly `<col> IS NOT NULL`, where `<col>` is the column EVERY MIN/MAX aggregate
/// here reads — redundant (MIN/MAX already ignore NULLs), so the index-seek fast paths may treat it as
/// no WHERE and still be byte-identical. Handles the single aggregate and the `MIN(col), MAX(col)` pair.
/// bd-minmax-redundant-not-null.
fn minmax_where_is_redundant_not_null(
where_clause: Option<&Expr>,
agg_columns: &[AggColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let Some(Expr::IsNull {
expr, not: true, ..
}) = where_clause
else {
return false;
};
let Some(filter_col) = column_name(expr, table, table_alias) else {
return false;
};
!agg_columns.is_empty()
&& agg_columns.iter().all(|agg| {
matches!(agg.name.as_str(), "MIN" | "MAX")
&& agg
.arg_col_index
.and_then(|i| table.columns.get(i))
.is_some_and(|c| c.name.eq_ignore_ascii_case(&filter_col))
})
}
/// Detect `SELECT MIN(col)` / `SELECT MAX(col)` (no WHERE/HAVING/GROUP BY) where `col` is the leading
/// term of an index whose ordering matches the aggregate comparison.
///
/// The value extremum is then a single seek to one end of the index rather than an O(n) scan. This
/// mirrors [`minmax_rowid_seek_plan`]'s guards (exactly one output aggregate; no DISTINCT / FILTER /
/// bare output column / multi-aggregate wrapper / extra args; a single plain-column argument) so the
/// produced result is byte-identical to the full-scan path — only the row that feeds `AggStep`
/// changes. A collation-matched single-column ASC index is preferred; the BINARY fallback also
/// accepts a composite index led by the column (ASC or DESC) and a single-column DESC index. A
/// differently-collated index, non-BINARY fallback, or WITHOUT ROWID table declines to the scan. A
/// scalar wrapper (`COALESCE(MAX(x), 0)`) is allowed — it is applied unchanged after finalize.
/// bd-minmax-index-seek.
fn minmax_index_seek_plan(
agg_columns: &[AggColumn],
table: &TableSchema,
) -> Option<MinMaxIndexSeek> {
let [agg] = agg_columns else {
return None;
};
if agg.arg_is_rowid
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
let is_max = match agg.name.as_str() {
"MIN" => false,
"MAX" => true,
_ => return None,
};
let col_name = table.columns.get(agg.arg_col_index?)?.name.as_str();
let (idx, descending) = find_minmax_leading_index(table, col_name, agg.collation.as_deref())?;
Some(MinMaxIndexSeek {
is_max,
index_name: idx.name.clone(),
index_root: idx.root_page,
descending,
})
}
/// `SELECT MIN(col), MAX(col)` (in either order) over the SAME indexed `col` — resolvable by two seeks
/// to the two ends of the index instead of a full scan.
struct MinMaxPairSeek {
/// SELECT-list position (register offset) of the `MIN` output and the `MAX` output.
min_out_col: i32,
max_out_col: i32,
index_name: String,
index_root: i32,
/// `true` when the leading index term is DESC (the extremum ends and NULL region flip).
descending: bool,
}
/// Detect `SELECT MIN(col), MAX(col)` / `SELECT MAX(col), MIN(col)` (no WHERE/HAVING/GROUP BY) over the
/// same indexed column — both extrema are at the two ends of the index, so two seeks replace the scan.
///
/// Mirrors the single-seek guards for BOTH aggregates (plain-column arg; no DISTINCT/FILTER/bare/
/// multi/extra; no scalar wrapper — kept simple), requires the same column and matching collation, and
/// reuses [`find_minmax_leading_index`]. Byte-identical: each end feeds its own accumulator exactly as
/// the single-seek path would.
fn minmax_pair_seek_plan(agg_columns: &[AggColumn], table: &TableSchema) -> Option<MinMaxPairSeek> {
let [a0, a1] = agg_columns else {
return None;
};
for agg in [a0, a1] {
if agg.arg_is_rowid
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| agg.wrapper_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
}
// One MIN and one MAX (either SELECT order), over the same column and matching collation.
let (min_out_col, max_out_col) = match (a0.name.as_str(), a1.name.as_str()) {
("MIN", "MAX") => (0, 1),
("MAX", "MIN") => (1, 0),
_ => return None,
};
let col = a0.arg_col_index?;
if a1.arg_col_index? != col || a0.collation != a1.collation {
return None;
}
let col_name = table.columns.get(col)?.name.as_str();
let (idx, descending) = find_minmax_leading_index(table, col_name, a0.collation.as_deref())?;
Some(MinMaxPairSeek {
min_out_col,
max_out_col,
index_name: idx.name.clone(),
index_root: idx.root_page,
descending,
})
}
/// `SELECT MIN(col) FROM t WHERE col > c` / `MAX(col) FROM t WHERE col < c` (natural pairing) over an
/// INTEGER-affinity single-column-indexed column — resolvable by ONE seek to the bound.
struct MinMaxRangeSeek {
index_name: String,
index_root: i32,
/// The seek that lands on the extremum at the bound: `SeekGT`/`SeekGE` for `MIN col >[=] c`,
/// `SeekLT`/`SeekLE` for `MAX col <[=] c`.
seek_op: Opcode,
/// The integer bound.
bound: i64,
}
/// Extract `col OP <int literal>` (OP ∈ `<,<=,>,>=`, either operand order) from a WHERE that is
/// exactly that comparison.
fn extract_col_int_comparison(
where_clause: Option<&Expr>,
table: &TableSchema,
table_alias: Option<&str>,
col_name: &str,
) -> Option<(fsqlite_ast::BinaryOp, i64)> {
use fsqlite_ast::BinaryOp::{Ge, Gt, Le, Lt};
let Expr::BinaryOp {
left, op, right, ..
} = where_clause?
else {
return None;
};
if !matches!(op, Lt | Le | Gt | Ge) {
return None;
}
let is_col = |e: &Expr| {
column_name(e, table, table_alias).is_some_and(|n| n.eq_ignore_ascii_case(col_name))
};
let int_lit = |e: &Expr| match e {
Expr::Literal(Literal::Integer(n), _) => Some(*n),
_ => None,
};
if is_col(left)
&& let Some(n) = int_lit(right)
{
return Some((*op, n));
}
// Reversed operand order: `<int> OP col` ≡ `col <reversed-OP> <int>`.
if is_col(right)
&& let Some(n) = int_lit(left)
{
let rev = match op {
Lt => Gt,
Le => Ge,
Gt => Lt,
Ge => Le,
_ => return None,
};
return Some((rev, n));
}
None
}
/// Detect `SELECT MIN(col) WHERE col >[=] c` / `SELECT MAX(col) WHERE col <[=] c` (the "natural"
/// pairing where the bound IS the extremum) over an INTEGER-affinity, single-column ASC-indexed column
/// with an integer literal bound — resolvable by one seek to the bound instead of a full scan.
///
/// INTEGER affinity + integer literal makes the seek exact: the index's storage-class order matches the
/// WHERE comparison's, so `SeekGT([c])` lands exactly on the first `col > c` (NULLs, which fail
/// `col > c`, sort first and are skipped), and `SeekLT([c])` on the last `col < c`. The opposite
/// pairings (MIN with an upper bound, MAX with a lower bound) yield the GLOBAL extremum — a different
/// shape — and decline. bd-minmax-range-seek.
fn minmax_range_seek_plan(
agg_columns: &[AggColumn],
table: &TableSchema,
table_alias: Option<&str>,
where_clause: Option<&Expr>,
) -> Option<MinMaxRangeSeek> {
use fsqlite_ast::BinaryOp::{Ge, Gt, Le, Lt};
let [agg] = agg_columns else {
return None;
};
if agg.arg_is_rowid
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
let is_max = match agg.name.as_str() {
"MIN" => false,
"MAX" => true,
_ => return None,
};
let col = table.columns.get(agg.arg_col_index?)?;
// INTEGER affinity + integer literal ⇒ the seek is exact (no affinity-skew fallback needed).
if col.affinity != 'D' {
return None;
}
let (op, bound) = extract_col_int_comparison(where_clause, table, table_alias, &col.name)?;
let seek_op = match (is_max, op) {
(false, Gt) => Opcode::SeekGT, // MIN col > c → first entry > c
(false, Ge) => Opcode::SeekGE, // MIN col >= c → first entry >= c
(true, Lt) => Opcode::SeekLT, // MAX col < c → last entry < c
(true, Le) => Opcode::SeekLE, // MAX col <= c → last entry <= c
_ => return None, // unnatural pairing → global extremum, different shape
};
let idx =
table.single_column_index_for_column_with_collation(&col.name, agg.collation.as_deref())?;
Some(MinMaxRangeSeek {
index_name: idx.name.clone(),
index_root: idx.root_page,
seek_op,
bound,
})
}
/// Detect `SELECT MIN(id) WHERE id >[=] c` / `MAX(id) WHERE id <[=] c` over the INTEGER PRIMARY KEY
/// (rowid) with an integer bound — resolvable by one seek on the table b-tree.
///
/// The rowid is a unique, never-NULL integer, so the seek is trivially exact and needs no NULL/tie
/// handling: `SeekGT`/`SeekGE` on the table lands on the first `id > c` / `>= c` (the min), `SeekLT`/
/// `SeekLE` on the last `id < c` / `<= c` (the max). Natural pairing only. Returns the seek opcode and
/// the integer bound. bd-minmax-rowid-range-seek.
fn minmax_rowid_range_seek_plan(
agg_columns: &[AggColumn],
table: &TableSchema,
table_alias: Option<&str>,
where_clause: Option<&Expr>,
) -> Option<(Opcode, i64)> {
use fsqlite_ast::BinaryOp::{Ge, Gt, Le, Lt};
let [agg] = agg_columns else {
return None;
};
// The aggregate is over the rowid: resolved either as the rowid alias, or as the ipk column.
let is_rowid_agg = agg.arg_is_rowid
|| agg
.arg_col_index
.is_some_and(|i| table.columns.get(i).is_some_and(|c| c.is_ipk));
if !is_rowid_agg
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
|| table.without_rowid
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
let is_max = match agg.name.as_str() {
"MIN" => false,
"MAX" => true,
_ => return None,
};
// WHERE (rowid) OP <int literal>, either operand order. Rowid references (`id`, `rowid`, …) bypass
// `column_name` (which returns None for them), so match them with `is_rowid_expr`.
let Expr::BinaryOp {
left, op, right, ..
} = where_clause?
else {
return None;
};
if !matches!(op, Lt | Le | Gt | Ge) {
return None;
}
let is_rowid = |e: &Expr| is_rowid_expr(e, Some(table), table_alias);
let int_lit = |e: &Expr| match e {
Expr::Literal(Literal::Integer(n), _) => Some(*n),
_ => None,
};
let (op, bound) = if is_rowid(left)
&& let Some(n) = int_lit(right)
{
(*op, n)
} else if is_rowid(right)
&& let Some(n) = int_lit(left)
{
let rev = match op {
Lt => Gt,
Le => Ge,
Gt => Lt,
Ge => Le,
_ => return None,
};
(rev, n)
} else {
return None;
};
let seek_op = match (is_max, op) {
(false, Gt) => Opcode::SeekGT,
(false, Ge) => Opcode::SeekGE,
(true, Lt) => Opcode::SeekLT,
(true, Le) => Opcode::SeekLE,
_ => return None, // unnatural pairing → global extremum, different shape
};
Some((seek_op, bound))
}
/// A single `MIN(b)`/`MAX(b)` over the SECOND key term of a composite index, constrained by
/// `WHERE <first-term> = <const>` — resolvable by one prefix seek to the extremum of the group.
struct MinMaxPrefixSeek<'e> {
/// `true` = `MAX`; `false` = `MIN`.
is_max: bool,
/// `true` when key term 1 (`b`) is DESC — then `MAX(b)` is the block's FIRST entry (`SeekGE`),
/// not the last (`SeekLE`). Only ever set for MAX (a DESC `b` declines MIN).
b_descending: bool,
index_name: String,
index_root: i32,
/// Affinity to coerce the `a` probe to (`'C'|'D'|'E'|'B'`), or `None` (untyped → no coercion).
a_affinity: Option<char>,
/// Collation for the `a`-block verify comparison (index key term 0), `None` for BINARY.
a_collation: Option<String>,
/// The `WHERE a = <const>` right-hand side (a literal or placeholder).
a_target: &'e Expr,
}
/// Detect `SELECT MIN(b)/MAX(b) FROM t WHERE a = <const>` where a composite index `(a, b, …)` has
/// `a` as key term 0 and `b` as key term 1 (both ASC, `b`'s collation matching the aggregate).
///
/// The extremum of `b` within the `a=?` group is then a single prefix seek — `SeekLE([a])` lands on
/// the last entry of the block (its max `b`), `SeekGE([a])` on the first (walk past leading `b`-NULLs
/// for min) — instead of scanning the whole group. Mirrors the plain-MIN/MAX guards (one output
/// aggregate; no DISTINCT/FILTER/bare/multi/extra; a single plain-column arg). Byte-identical: `b` is
/// read from the index (same affinity-applied value a scan sees) and the index order equals the
/// MIN/MAX comparison order. A DESC term, a collation mismatch, or WITHOUT ROWID declines to the scan.
/// bd-minmax-prefix-seek.
fn minmax_prefix_seek_plan<'e>(
agg_columns: &[AggColumn],
table: &TableSchema,
table_alias: Option<&str>,
where_clause: Option<&'e Expr>,
) -> Option<MinMaxPrefixSeek<'e>> {
let [agg] = agg_columns else {
return None;
};
if agg.arg_is_rowid
|| agg.distinct
|| agg.filter.is_some()
|| agg.hidden
|| agg.bare_expr.is_some()
|| agg.arg_expr.is_some()
|| !agg.multi_agg_indices.is_empty()
|| !agg.extra_args.is_empty()
|| agg.num_args != 1
{
return None;
}
if !builtin_aggregate_semantics_available(&agg.name, 1) {
return None;
}
if table.without_rowid {
return None;
}
let is_max = match agg.name.as_str() {
"MIN" => false,
"MAX" => true,
_ => return None,
};
let b_col_name = table.columns.get(agg.arg_col_index?)?.name.clone();
// BINARY `b` only: under a non-BINARY collation (e.g. NOCASE) collation-equal values can be
// byte-different, so the index's tie representative (chosen by rowid) may differ from the scan's
// MIN/MAX representative — not byte-identical. Such shapes decline to the group scan.
if agg
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
let (a_col_name, a_target) = extract_column_eq_target(where_clause, table, table_alias)?;
if a_col_name.eq_ignore_ascii_case(&b_col_name) {
return None;
}
let idx = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() >= 2
&& !idx.key_term_descending(0)
// ASC `b`: MIN=first / MAX=last of the block. DESC `b`: MAX is the block's FIRST entry
// (SeekGE, O(log n) — no O(block) walk); MIN over a DESC `b` (last non-NULL, SeekLE) is
// declined here, so a DESC second term is only accepted for MAX.
&& (!idx.key_term_descending(1) || is_max)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&a_col_name))
&& idx
.columns
.get(1)
.is_some_and(|c| c.eq_ignore_ascii_case(&b_col_name))
&& idx
.key_term_collation(1)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
})?;
let b_descending = idx.key_term_descending(1);
let a_affinity = table
.column_index(&a_col_name)
.map(|i| table.columns[i].affinity)
.filter(|&aff| matches!(aff, 'C' | 'D' | 'E' | 'B'));
let a_collation = idx
.key_term_collation(0)
.filter(|c| !c.eq_ignore_ascii_case("BINARY"))
.map(str::to_owned);
Some(MinMaxPrefixSeek {
is_max,
b_descending,
index_name: idx.name.clone(),
index_root: idx.root_page,
a_affinity,
a_collation,
a_target,
})
}
/// Emit the `MAX(rowid)` / `MIN(rowid)` leaf-seek fast path.
///
/// Instead of `Rewind` + AggStep-per-row + `Next` (an O(n) walk), this seeks a
/// single leaf — `Last` for `MAX`, `Rewind` for `MIN` — feeds that one row's
/// rowid through the *same* `AggStep`/`AggFinal`/wrapper sequence the slow path
/// uses, and emits the result. On an empty table the seek jumps straight to
/// finalize with the accumulator still `NULL`, so `MAX(id)` → `NULL` and
/// `COALESCE(MAX(id), 0)` → `0`, identical to stock SQLite.
///
/// Returns `Result` to mirror the sibling codegen entry points and the
/// dispatcher's `return codegen_select_minmax_rowid_seek(...)` call site, even
/// though this path is infallible.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_rowid_seek(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
agg_columns: &[AggColumn],
seek_end: RowidSeekEnd,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let agg = &agg_columns[0];
// Single accumulator, initialized to NULL (AggStep protocol). When the
// table is empty the seek skips AggStep and the accumulator stays NULL.
let accum_reg = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// Seek the single extremum row; jump past AggStep to finalize when empty.
let finalize_label = b.emit_label();
let seek_op = match seek_end {
RowidSeekEnd::First => Opcode::Rewind,
RowidSeekEnd::Last => Opcode::Last,
};
b.emit_jump_to_label(seek_op, cursor, 0, finalize_label, P4::None, 0);
// AggStep over exactly one row: the rowid of the leaf we landed on.
let arg_reg = b.alloc_reg();
b.emit_op(Opcode::Rowid, cursor, arg_reg, 0, P4::None, 0);
let agg_p4 = agg_func_p4(&agg.name, agg.collation.as_ref());
b.emit_op(Opcode::AggStep, 0, arg_reg, accum_reg, agg_p4, 1);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
// Move the finalized value into the output register (when distinct).
if accum_reg != out_regs {
b.emit_op(Opcode::Copy, accum_reg, out_regs, 0, P4::None, 0);
}
// Apply any scalar wrapper (e.g. COALESCE(..., 0)) exactly as the slow path.
if let Some(wrapper) = &agg.wrapper_expr {
emit_agg_wrapper(b, wrapper, out_regs);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit the `MIN(col)` / `MAX(col)` single-seek fast path over a secondary index on `col`.
///
/// `MAX` seeks the maximum end (ASC → `Last`, DESC → `Rewind`). `MIN` seeks the minimum end and
/// walks past the NULL region toward values (ASC → `Rewind` + `Next`, DESC → `Last` + `Prev`), since
/// `min()` ignores NULLs. An empty or all-NULL index leaves the accumulator NULL. Exactly one
/// non-NULL row's value feeds the same `AggStep`/`AggFinal`/wrapper sequence the full-scan path uses,
/// so the result is bit-identical. Only the index cursor is opened — the value is index column 0, so
/// no table lookup is needed. bd-minmax-index-seek.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_index_seek(
b: &mut ProgramBuilder,
idx_cursor: i32,
agg_columns: &[AggColumn],
seek: &MinMaxIndexSeek,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let agg = &agg_columns[0];
// Single accumulator, initialized to NULL (AggStep protocol). An empty or all-NULL index skips
// AggStep, leaving NULL — identical to the empty/all-NULL full scan.
let accum_reg = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
seek.index_root,
0,
P4::Index(seek.index_name.clone()),
0,
);
let finalize_label = b.emit_label();
let arg_reg = b.alloc_reg();
if seek.is_max {
// MAX: single seek to the max end — ASC index → last entry, DESC index → first entry. NULLs
// sit at the opposite end, so this entry is non-NULL unless the column is all-NULL (then it is
// NULL and MAX(NULL) = NULL). Empty → finalize (NULL).
let max_end = if seek.descending {
Opcode::Rewind
} else {
Opcode::Last
};
b.emit_jump_to_label(max_end, idx_cursor, 0, finalize_label, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, arg_reg, P4::None, 0);
} else {
// MIN: start at the min end and skip the NULL region toward the values — ASC → Rewind + Next,
// DESC → Last + Prev. The first non-NULL reached is the minimum; all-NULL/empty → NULL.
let (min_end, skip_op) = if seek.descending {
(Opcode::Last, Opcode::Prev)
} else {
(Opcode::Rewind, Opcode::Next)
};
b.emit_jump_to_label(min_end, idx_cursor, 0, finalize_label, P4::None, 0);
let scan_top = b.current_addr();
let step_label = b.emit_label();
b.emit_op(Opcode::Column, idx_cursor, 0, arg_reg, P4::None, 0);
// Non-NULL → accumulate it (it is the minimum). NULL → advance one entry toward the values.
b.emit_jump_to_label(Opcode::NotNull, arg_reg, 0, step_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let scan_body = scan_top as i32;
// Next/Prev jumps back to re-test; falls through (all NULL) to finalize.
b.emit_op(skip_op, idx_cursor, scan_body, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
b.resolve_label(step_label);
}
let agg_p4 = agg_func_p4(&agg.name, agg.collation.as_ref());
b.emit_op(Opcode::AggStep, 0, arg_reg, accum_reg, agg_p4, 1);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
if accum_reg != out_regs {
b.emit_op(Opcode::Copy, accum_reg, out_regs, 0, P4::None, 0);
}
if let Some(wrapper) = &agg.wrapper_expr {
emit_agg_wrapper(b, wrapper, out_regs);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit `SELECT COUNT(DISTINCT col) FROM t` as a single covering index walk: open the single-column
/// ASC index, skip the leading NULL run (DISTINCT ignores NULLs), and increment the count once per
/// key-change. No ephemeral dedup B-tree and no table read. Byte-identical to the full-scan DISTINCT
/// path because the BINARY index groups equal values adjacently. bd-count-distinct-index-walk.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_count_distinct_index_walk(
b: &mut ProgramBuilder,
idx_cursor: i32,
walk: &CountDistinctIndexWalk,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) {
// Count accumulates directly in the output register (COUNT is never NULL; wrappers are declined).
let count_reg = out_regs;
let prev_reg = b.alloc_reg();
let have_prev_reg = b.alloc_reg();
let key_reg = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, count_reg, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, have_prev_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
walk.index_root,
0,
P4::Index(walk.index_name.clone()),
0,
);
let finalize_label = b.emit_label();
// Empty index → count stays 0.
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, finalize_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let scan_top = b.current_addr() as i32;
let next_label = b.emit_label();
let count_label = b.emit_label();
b.emit_op(Opcode::Column, idx_cursor, 0, key_reg, P4::None, 0);
// DISTINCT ignores NULLs — skip the leading NULL run entirely.
b.emit_jump_to_label(Opcode::IsNull, key_reg, 0, next_label, P4::None, 0);
// First non-NULL is a new distinct value; otherwise count only when the key differs from the prev.
b.emit_jump_to_label(Opcode::IfNot, have_prev_reg, 0, count_label, P4::None, 0);
// Eq p1==p3 → jump (skip): key equals the previous distinct value, not new.
b.emit_jump_to_label(Opcode::Eq, key_reg, prev_reg, next_label, P4::None, 0);
b.resolve_label(count_label);
b.emit_op(Opcode::AddImm, count_reg, 1, 0, P4::None, 0);
b.emit_op(Opcode::Copy, key_reg, prev_reg, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 1, have_prev_reg, 0, P4::None, 0);
b.resolve_label(next_label);
b.emit_op(Opcode::Next, idx_cursor, scan_top, 0, P4::None, 0);
b.resolve_label(finalize_label);
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
}
/// A `SELECT DISTINCT <col> FROM t` (no WHERE/GROUP BY/HAVING/LIMIT/ORDER BY) served by a loose/skip
/// index scan: emit each distinct value once, then `SeekGT [value, i64::MAX]` past the value's whole
/// duplicate run to the next distinct value. Work scales with the number of DISTINCT values, not rows.
/// bd-distinct-loose-scan.
/// Cheap `Next` attempts per distinct leading value before the skip scan pays a root-to-leaf
/// `SeekGE [leading, const]`. Keeps a near-unique leading column at full-index-walk cost (never worse
/// than the covering scan it replaces) instead of one seek per row.
const SKIP_SCAN_WALK_PROBES: usize = 3;
/// `SELECT <cols> FROM t WHERE <second_col> = <const>` where `<second_col>` is the SECOND key term of a
/// two-column ASC BINARY index whose LEADING term is a DIFFERENT (unconstrained) column — a MySQL-style
/// skip scan candidate. bd-nax2y.
struct SkipScanEqTarget<'a> {
index: &'a IndexSchema,
const_expr: &'a Expr,
}
/// Detect the skip-scan shape: the WHOLE WHERE is `<col> = <const>`, `<col>` is a plain BINARY
/// non-generated column that is the SECOND term of a two-column ASC BINARY index whose leading term is
/// a different column (so the leading term is unconstrained by the WHERE). The seek is
/// residual-free (the whole WHERE is the equality), so no residual filter is dropped.
fn skip_scan_eq_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&str>,
) -> Option<SkipScanEqTarget<'a>> {
if table.without_rowid {
return None;
}
let (col_name, const_expr) = extract_column_eq_target(where_clause, table, table_alias)?;
let col_idx = table.column_index(&col_name)?;
let column = table.columns.get(col_idx)?;
if column.generated_expr.is_some()
|| column
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
// If a single-column index on the target column exists, the equality seek (dispatched earlier)
// serves `col = const` directly and more cheaply than a skip scan — decline so it wins.
if table.indexes.iter().any(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
}) {
return None;
}
// Any index with >= 2 plain ASC BINARY key terms whose SECOND term is the constrained column and
// whose FIRST (leading) term is a different, unconstrained column. Trailing key terms beyond the
// second (`idx(a, b, c, …)`) are unconstrained too — the seek fills them with the `i64::MIN`
// sentinel so `SeekGE` still lands on the FIRST `(leading, const, …)` entry, and the run emits every
// matching row regardless of the trailing columns. ALL key terms must be ASC BINARY: a DESC or
// non-BINARY trailing term would order the sentinel wrong and the seek could overshoot.
let index = table.indexes.iter().find(|idx| {
let kt = idx.key_term_count();
idx.supports_direct_column_lookup()
&& kt >= 2
&& idx.columns.len() == kt
&& (0..kt).all(|i| {
!idx.key_term_descending(i)
&& idx
.key_term_collation(i)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
})
&& idx
.columns
.get(1)
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
&& idx
.columns
.first()
.is_some_and(|c| !c.eq_ignore_ascii_case(&col_name))
})?;
Some(SkipScanEqTarget { index, const_expr })
}
/// The `>= 2`-column ASC BINARY index whose SECOND key term is `col_name` (a plain non-generated BINARY
/// column) and whose LEADING term differs — the skip-scan candidate for a constraint on `col_name`.
/// Declines when a single-column index on `col_name` exists (it serves the constraint directly).
fn skip_scan_second_col_index<'a>(
table: &'a TableSchema,
col_name: &str,
) -> Option<&'a IndexSchema> {
let column = table
.column_index(col_name)
.and_then(|i| table.columns.get(i))?;
if column.generated_expr.is_some()
|| column
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
if table.indexes.iter().any(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
}) {
return None;
}
table.indexes.iter().find(|idx| {
let kt = idx.key_term_count();
idx.supports_direct_column_lookup()
&& kt >= 2
&& idx.columns.len() == kt
&& (0..kt).all(|i| {
!idx.key_term_descending(i)
&& idx
.key_term_collation(i)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
})
&& idx
.columns
.get(1)
.is_some_and(|c| c.eq_ignore_ascii_case(col_name))
&& idx
.columns
.first()
.is_some_and(|c| !c.eq_ignore_ascii_case(col_name))
})
}
/// `SELECT <cols> FROM t WHERE <second_col> IS NULL` where `<second_col>` is the SECOND key term of a
/// `>= 2`-column ASC BINARY index with an unconstrained leading term. bd-nax2y IS-NULL extension.
struct SkipScanIsNullTarget<'a> {
index: &'a IndexSchema,
}
/// Detect `WHERE <col> IS NULL` on the second key term of a skip-scan-able index. The `(x, NULL)` run is
/// at the START of each leading block (NULLs sort first), so the scan emits it and advances past the
/// rest of the block — no seek within a block.
fn skip_scan_is_null_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&str>,
) -> Option<SkipScanIsNullTarget<'a>> {
if table.without_rowid {
return None;
}
let Expr::IsNull {
expr, not: false, ..
} = where_clause?
else {
return None;
};
let col_name = column_name(expr, table, table_alias)?;
let index = skip_scan_second_col_index(table, &col_name)?;
Some(SkipScanIsNullTarget { index })
}
/// A range skip-scan target: the constrained SECOND key term carries a range (with an inclusive lower
/// bound) instead of an equality. bd-nax2y range extension.
struct SkipScanRangeTarget<'a> {
index: &'a IndexSchema,
range: ColumnRangeTarget<'a>,
}
/// True if `term` is a range bound (`<`,`<=`,`>`,`>=`) or `BETWEEN` on `column` and nothing else — used
/// to prove the WHOLE WHERE is a range on the target so the residual-free seek drops no predicate.
fn conjunct_is_range_bound_on_column(
term: &Expr,
column: &str,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
match term {
Expr::BinaryOp {
left, op, right, ..
} => extract_column_range_bound(left, *op, right, table, table_alias)
.is_some_and(|(col, _, _)| col.eq_ignore_ascii_case(column)),
Expr::Between {
expr,
low,
high,
not: false,
..
} => {
is_index_range_constant(low)
&& is_index_range_constant(high)
&& column_name(expr, table, table_alias)
.is_some_and(|c| c.eq_ignore_ascii_case(column))
}
_ => false,
}
}
/// Detect a RANGE skip scan: the WHOLE WHERE is a range (any of `<`,`<=`,`>`,`>=`,`BETWEEN`) on a plain
/// BINARY column that is the SECOND key term of a `>= 2`-column ASC BINARY index whose leading term
/// differs (unconstrained). Inclusive lower `>=` seeks `SeekGE(x, lo, NULL…, -inf)`; exclusive `>` seeks
/// a 2-field `SeekGT(x, lo)` past the whole `(x, lo, *)` run; a no-lower `<`/`<=` walks past the NULL
/// prefix run (no seekable lower). Residual-safe: EVERY conjunct must be a bound on the target (else the
/// seek would silently drop it). Declines when a single-column index on the target exists (a plain range
/// seek serves that directly).
fn skip_scan_range_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&str>,
) -> Option<SkipScanRangeTarget<'a>> {
if table.without_rowid {
return None;
}
let where_expr = where_clause?;
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
for index in &table.indexes {
let kt = index.key_term_count();
if !(index.supports_direct_column_lookup()
&& kt >= 2
&& index.columns.len() == kt
&& (0..kt).all(|i| {
!index.key_term_descending(i)
&& index
.key_term_collation(i)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
}))
{
continue;
}
let Some(target) = index.columns.get(1) else {
continue;
};
// Leading term must be a DIFFERENT (unconstrained) column.
if index
.columns
.first()
.is_none_or(|c| c.eq_ignore_ascii_case(target))
{
continue;
}
// Target must be a plain, non-generated BINARY column.
let Some(column) = table
.column_index(target)
.and_then(|i| table.columns.get(i))
else {
continue;
};
if column.generated_expr.is_some()
|| column
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
continue;
}
// A single-column index on the target serves the range directly and more cheaply.
if table.indexes.iter().any(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(target))
}) {
continue;
}
// The whole WHERE must be a range on the target, with an inclusive lower bound present, and no
// conjunct on any other column (else the residual-free seek would drop it).
let Some(range) = extract_named_column_range(where_expr, table, table_alias, target) else {
continue;
};
if !conjuncts
.iter()
.all(|term| conjunct_is_range_bound_on_column(term, target, table, table_alias))
{
continue;
}
return Some(SkipScanRangeTarget { index, range });
}
None
}
/// Whether the skip scan's emission — leading key column ASC (NULLs first, as the leftmost index
/// entries), then the second key column ASC, then rowid ASC — satisfies `order_by` as a deterministic
/// TOTAL order, so the sorter can be elided. `second_varies` is true for the range scan (the 2nd column
/// is a live sort key) and false for the equality scan (the 2nd column is a constant, so it may be
/// present or absent). Every term must be plain-ASC (no DESC, no explicit NULLS) with a collation
/// matching the (BINARY) index term; the final term is the rowid, the unique tiebreaker.
fn skip_scan_order_by_satisfied(
index: &IndexSchema,
second_varies: bool,
order_by: &[OrderingTerm],
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let asc_plain = |term: &OrderingTerm| {
term.nulls.is_none() && !matches!(term.direction, Some(SortDirection::Desc))
};
let is_key = |term: &OrderingTerm, key_pos: usize| {
asc_plain(term)
&& index.columns.get(key_pos).is_some_and(|col| {
matches!(
resolve_column_ref(&term.expr, table, table_alias),
Some(SortKeySource::Column(idx))
if table.columns.get(idx).is_some_and(|c| c.name.eq_ignore_ascii_case(col))
)
})
&& collation_names_equivalent(
column_collation(&term.expr, table, table_alias),
index.key_term_collation(key_pos),
)
};
let is_rowid = |term: &OrderingTerm| {
asc_plain(term)
&& matches!(
resolve_column_ref(&term.expr, table, table_alias),
Some(SortKeySource::Rowid)
)
};
match order_by {
// `leading, rowid` — equality only (the constant 2nd column may be omitted).
[t0, t1] if !second_varies => is_key(t0, 0) && is_rowid(t1),
// `leading, second, rowid`.
[t0, t1, t2] => is_key(t0, 0) && is_key(t1, 1) && is_rowid(t2),
_ => false,
}
}
/// Emit the skip scan. Enumerate distinct leading values; per value, adaptively WALK a few entries
/// (checking `a == const`, emitting matches) and, only for a large block, `SeekGE [x, const]` to jump
/// to the constrained run — then `SeekGT [x]` to the next distinct leading value. A near-unique
/// leading column never seeks (the walk finds the next value first), so it degrades to a full index
/// walk (== the covering scan it replaces); a low-cardinality leading column pays ~2 seeks per value.
/// The walk NEVER emits a `(x, const)` entry that the post-walk seek would re-find: the walk only
/// falls through to the seek when the last inspected `a < const` (the const run is entirely ahead).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_skip_scan(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
const_expr: &Expr,
limit_clause: Option<&LimitClause>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let target_aff = idx_schema
.columns
.get(1)
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&a| matches!(a, 'C' | 'D' | 'E' | 'B'));
let const_reg = b.alloc_reg();
emit_expr(b, const_expr, const_reg, None);
if let Some(aff) = target_aff
&& !bound_matches_affinity(aff, const_expr)
{
b.emit_op(
Opcode::Affinity,
const_reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
// `col = NULL` matches nothing in SQLite: a NULL constant yields zero rows.
b.emit_jump_to_label(Opcode::IsNull, const_reg, 0, done_label, P4::None, 0);
// LIMIT/OFFSET streaming (only reached when a matching ORDER BY makes the top-N deterministic).
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let covering = resolve_covering_output_sources(columns, table, table_alias, idx_schema);
let needs_table = covering.is_none();
if needs_table {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, done_label, P4::None, 0);
let x_reg = b.alloc_reg();
let cur_x_reg = b.alloc_reg();
let a_reg = b.alloc_reg();
// Probe = [leading, const, MIN × (key_terms - 1)]: a full index key (every trailing key term plus
// the rowid filled with the `i64::MIN` sentinel). This engine pads a SHORT seek probe toward the
// HIGH end (which is why the 1-field `SeekGT` advance below correctly lands past the whole `(x, *)`
// run), so a bare `(x, const)` `SeekGE` would overshoot PAST the `(x, const, …)` run instead of
// landing on its first entry. `MIN` sorts before every real value, anchoring `SeekGE` on the first
// `(x, const, …)` across any >=2-col index — mirrors the composite prefix+range seek.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let key_terms = idx_schema.key_term_count() as i32;
let probe_base = b.alloc_regs(key_terms + 1);
let probe_rec = b.alloc_reg();
let rowid_reg = b.alloc_reg();
let emit_run = b.emit_label();
let advance = b.emit_label();
let bin = || P4::Collation("BINARY".to_owned());
// outer: positioned at the first entry of a new distinct leading value.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let outer = b.current_addr() as i32;
b.emit_op(Opcode::Column, idx_cursor, 0, x_reg, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
b.emit_jump_to_label(Opcode::Eq, a_reg, const_reg, emit_run, bin(), 0);
// `Gt(const, a)` jumps on `a > const` (jump cond is `reg[P3] OP reg[P1]`); see the walk copy below.
b.emit_jump_to_label(Opcode::Gt, const_reg, a_reg, advance, bin(), 0);
// a < const (or NULL): adaptive walk.
for _ in 0..SKIP_SCAN_WALK_PROBES {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let next_addr = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
b.emit_jump_to_label(Opcode::Eq, a_reg, const_reg, emit_run, bin(), 0);
// `emit_jump_to_label(OP, P1, P3)` jumps when `reg[P3] OP reg[P1]`, so to advance on `a > const`
// the const is P1 and `a` is P3 (a bare `Gt(a, const)` would test `const > a` == `a < const` and
// wrongly skip the whole block on its first sub-const row). `Eq`/`Ne` above are symmetric.
b.emit_jump_to_label(Opcode::Gt, const_reg, a_reg, advance, bin(), 0);
}
// Large block, still a < const: seek straight to the first (x, const, NULL…, -inf). Trailing KEY
// columns get NULL (which sorts BEFORE every value, so the seek lands on the FIRST `(x, const, …)`
// entry — a MIN integer would skip past `(x, const, NULL, …)` rows since NULL sorts below it); only
// the rowid slot gets `i64::MIN` (rowids are never NULL and MIN sorts before every real rowid).
b.emit_op(Opcode::Copy, x_reg, probe_base, 0, P4::None, 0);
b.emit_op(Opcode::Copy, const_reg, probe_base + 1, 0, P4::None, 0);
for off in 2..key_terms {
b.emit_op(Opcode::Null, 0, probe_base + off, 0, P4::None, 0);
}
b.emit_op(
Opcode::Int64,
0,
probe_base + key_terms,
0,
P4::Int64(i64::MIN),
0,
);
b.emit_op(
Opcode::MakeRecord,
probe_base,
key_terms + 1,
probe_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
b.emit_jump_to_label(Opcode::Ne, a_reg, const_reg, advance, bin(), 0);
// fall through to emit_run.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_run_addr = b.current_addr() as i32;
b.resolve_label(emit_run);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
b.emit_jump_to_label(Opcode::Ne, a_reg, const_reg, advance, bin(), 0);
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
let emit_next = b.emit_label();
if needs_table {
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, emit_next, P4::None, 0);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, emit_next, P4::None, 0);
}
if let Some(cov) = &covering {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(emit_next);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_next_addr = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, emit_next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Goto, 0, emit_run_addr, 0, P4::None, 0);
// advance: at (x, a>const); skip to the next distinct leading value.
b.resolve_label(advance);
b.emit_op(Opcode::Copy, x_reg, probe_base, 0, P4::None, 0);
b.emit_op(Opcode::MakeRecord, probe_base, 1, probe_rec, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekGT,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
b.emit_op(Opcode::Goto, 0, outer, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit the three range-membership jumps for one index entry whose second-key value is in `a_reg`:
/// jump to `advance` when it is ABOVE the range (`a > up` inclusive / `a >= up` exclusive), else jump
/// to `emit_run` when it has reached the lower (`a >= lo` inclusive / `a > lo` exclusive), else fall
/// through (below the range or NULL — the caller keeps walking). `emit_jump_to_label(OP, P1, P3)` jumps
/// on `reg[P3] OP reg[P1]`, so the bound register is P1 and `a` is P3 (matches the composite emitter).
fn emit_skip_scan_range_decision(
b: &mut ProgramBuilder,
upper: Option<(i32, bool)>,
lower: Option<(i32, bool)>,
a_reg: i32,
advance: crate::Label,
emit_run: crate::Label,
) {
let bin = || P4::Collation("BINARY".to_owned());
if let Some((up_reg, up_inclusive)) = upper {
let above = if up_inclusive { Opcode::Gt } else { Opcode::Ge };
b.emit_jump_to_label(above, up_reg, a_reg, advance, bin(), 0);
}
match lower {
Some((lo_reg, lo_inclusive)) => {
let reached = if lo_inclusive { Opcode::Ge } else { Opcode::Gt };
b.emit_jump_to_label(reached, lo_reg, a_reg, emit_run, bin(), 0);
}
// No lower bound: any NON-NULL value not above the upper is in range. NULLs sort first and are
// excluded, so they are the only "below" — the caller keeps walking on fall-through.
None => {
b.emit_jump_to_label(Opcode::NotNull, a_reg, 0, emit_run, P4::None, 0);
}
}
}
/// Range skip scan (bd-nax2y): the second key term carries a range with a lower bound (inclusive or
/// exclusive, optionally an upper bound) instead of an equality. Same distinct-leading-value iteration +
/// adaptive walk as [`codegen_select_skip_scan`], but each entry is classified by
/// [`emit_skip_scan_range_decision`] (above → advance to the next leading value, in-range → emit,
/// below/NULL → keep walking) and the seek anchors on the first in-range entry per leading value
/// (`SeekGE (x, lo, NULL…, -inf)` for `>=`, 2-field `SeekGT (x, lo)` for `>`). Byte-identical to C SQLite.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_skip_scan_range(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
range: &ColumnRangeTarget<'_>,
limit_clause: Option<&LimitClause>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
let target_aff = idx_schema
.columns
.get(1)
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&a| matches!(a, 'C' | 'D' | 'E' | 'B'));
let coerce = |b: &mut ProgramBuilder, reg: i32, expr: &Expr| {
if let Some(aff) = target_aff
&& !bound_matches_affinity(aff, expr)
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
};
// Lower bound (optional — absent for a no-lower `< hi` / `<= hi` range). A NULL bound matches
// nothing → zero rows.
let lower = range.lower.as_ref().map(|bound| {
let lo_expr = bound.expr();
let lo_reg = b.alloc_reg();
emit_expr(b, lo_expr, lo_reg, None);
coerce(b, lo_reg, lo_expr);
b.emit_jump_to_label(Opcode::IsNull, lo_reg, 0, done_label, P4::None, 0);
(lo_reg, bound.inclusive)
});
// Optional upper bound.
let upper = range.upper.as_ref().map(|bound| {
let up_reg = b.alloc_reg();
emit_expr(b, bound.expr(), up_reg, None);
coerce(b, up_reg, bound.expr());
b.emit_jump_to_label(Opcode::IsNull, up_reg, 0, done_label, P4::None, 0);
(up_reg, bound.inclusive)
});
// LIMIT/OFFSET streaming (only reached when a matching ORDER BY makes the top-N deterministic).
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let covering = resolve_covering_output_sources(columns, table, table_alias, idx_schema);
let needs_table = covering.is_none();
if needs_table {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, done_label, P4::None, 0);
let x_reg = b.alloc_reg();
let cur_x_reg = b.alloc_reg();
let a_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let key_terms = idx_schema.key_term_count() as i32;
let probe_base = b.alloc_regs(key_terms + 1);
let probe_rec = b.alloc_reg();
let rowid_reg = b.alloc_reg();
let emit_run = b.emit_label();
let advance = b.emit_label();
let bin = || P4::Collation("BINARY".to_owned());
// outer: positioned at the first entry of a new distinct leading value.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let outer = b.current_addr() as i32;
b.emit_op(Opcode::Column, idx_cursor, 0, x_reg, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
emit_skip_scan_range_decision(b, upper, lower, a_reg, advance, emit_run);
if let Some((lo_reg, lo_inclusive)) = lower {
// below the lower bound (or NULL): adaptive walk, then seek to the first in-range entry.
for _ in 0..SKIP_SCAN_WALK_PROBES {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let next_addr = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
emit_skip_scan_range_decision(b, upper, lower, a_reg, advance, emit_run);
}
// Large block, still below the lower bound: seek to the first in-range entry. Inclusive `>= lo`:
// `SeekGE (x, lo, NULL…, -inf)` lands on the first `(x, lo, …)`. Exclusive `> lo`: a 2-field
// `SeekGT (x, lo)` — the engine pads the short probe toward the HIGH end, so it lands past the
// whole `(x, lo, *)` run on the first `(x, b > lo)`.
b.emit_op(Opcode::Copy, x_reg, probe_base, 0, P4::None, 0);
b.emit_op(Opcode::Copy, lo_reg, probe_base + 1, 0, P4::None, 0);
if lo_inclusive {
for off in 2..key_terms {
b.emit_op(Opcode::Null, 0, probe_base + off, 0, P4::None, 0);
}
b.emit_op(
Opcode::Int64,
0,
probe_base + key_terms,
0,
P4::Int64(i64::MIN),
0,
);
b.emit_op(
Opcode::MakeRecord,
probe_base,
key_terms + 1,
probe_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
} else {
b.emit_op(Opcode::MakeRecord, probe_base, 2, probe_rec, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekGT,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
}
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
// Now at/after the lower bound, so this either advances (above upper) or emits.
emit_skip_scan_range_decision(b, upper, lower, a_reg, advance, emit_run);
b.emit_jump_to_label(Opcode::Goto, 0, 0, advance, P4::None, 0);
} else {
// No lower bound: the only sub-range values are the NULL prefix run at the block start (NULLs
// sort first and are excluded). Walk past them — unbounded, but NULL runs are short and the
// required upper bound still terminates the emitted run. No seek is possible without a lower.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let null_walk = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, null_walk + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
emit_skip_scan_range_decision(b, upper, lower, a_reg, advance, emit_run);
b.emit_op(Opcode::Goto, 0, null_walk, 0, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_run_addr = b.current_addr() as i32;
b.resolve_label(emit_run);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
// Sorted ascending: once in-range the lower bound stays satisfied, so only the upper bound can end
// the run (besides a leading-value change) — jump to advance when the value climbs above it.
if let Some((up_reg, up_inclusive)) = upper {
let above = if up_inclusive { Opcode::Gt } else { Opcode::Ge };
b.emit_jump_to_label(above, up_reg, a_reg, advance, bin(), 0);
}
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
let emit_next = b.emit_label();
if needs_table {
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, emit_next, P4::None, 0);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, emit_next, P4::None, 0);
}
if let Some(cov) = &covering {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(emit_next);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_next_addr = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, emit_next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Goto, 0, emit_run_addr, 0, P4::None, 0);
// advance: skip to the next distinct leading value.
b.resolve_label(advance);
b.emit_op(Opcode::Copy, x_reg, probe_base, 0, P4::None, 0);
b.emit_op(Opcode::MakeRecord, probe_base, 1, probe_rec, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekGT,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
b.emit_op(Opcode::Goto, 0, outer, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// IS-NULL skip scan (bd-nax2y): `WHERE <second_col> IS NULL`. The `(x, NULL)` run sits at the START of
/// each leading block (NULLs sort first), so per distinct leading value the scan emits that run and
/// `SeekGT [x]` to the next value — no walk or seek within a block. Streams `x ASC (NULLs first), rowid
/// ASC`, so a matching ORDER BY elides the sorter and LIMIT/OFFSET stream. Byte-identical to C SQLite.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_skip_scan_is_null(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
idx_schema: &IndexSchema,
limit_clause: Option<&LimitClause>,
) -> Result<(), CodegenError> {
let idx_cursor = cursor + 1;
// LIMIT/OFFSET streaming (only reached when a matching ORDER BY makes the top-N deterministic).
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let covering = resolve_covering_output_sources(columns, table, table_alias, idx_schema);
let needs_table = covering.is_none();
if needs_table {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, done_label, P4::None, 0);
let x_reg = b.alloc_reg();
let cur_x_reg = b.alloc_reg();
let a_reg = b.alloc_reg();
let probe_base = b.alloc_reg();
let probe_rec = b.alloc_reg();
let rowid_reg = b.alloc_reg();
let emit_run = b.emit_label();
let advance = b.emit_label();
let bin = || P4::Collation("BINARY".to_owned());
// outer: at the first entry of a new distinct leading value (the block's NULL run, if any).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let outer = b.current_addr() as i32;
b.emit_op(Opcode::Column, idx_cursor, 0, x_reg, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
// A non-NULL second key at the block start means the block has no NULL run — advance.
b.emit_jump_to_label(Opcode::NotNull, a_reg, 0, advance, P4::None, 0);
// fall through to emit_run (the second key is NULL).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_run_addr = b.current_addr() as i32;
b.resolve_label(emit_run);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_x_reg, P4::None, 0);
b.emit_op(Opcode::Ne, x_reg, outer, cur_x_reg, bin(), 0x80);
b.emit_op(Opcode::Column, idx_cursor, 1, a_reg, P4::None, 0);
// The NULL run ends at the first non-NULL second key (sorted ascending, NULLs first) — advance.
b.emit_jump_to_label(Opcode::NotNull, a_reg, 0, advance, P4::None, 0);
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
let emit_next = b.emit_label();
if needs_table {
b.emit_jump_to_label(Opcode::SeekRowid, cursor, rowid_reg, emit_next, P4::None, 0);
}
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, emit_next, P4::None, 0);
}
if let Some(cov) = &covering {
emit_covering_output_reads(b, idx_cursor, rowid_reg, cov, out_regs);
} else {
emit_column_reads(b, cursor, columns, table, table_alias, schema, out_regs)?;
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(emit_next);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let emit_next_addr = b.current_addr() as i32;
b.emit_op(Opcode::Next, idx_cursor, emit_next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Goto, 0, emit_run_addr, 0, P4::None, 0);
// advance: skip to the next distinct leading value.
b.resolve_label(advance);
b.emit_op(Opcode::Copy, x_reg, probe_base, 0, P4::None, 0);
b.emit_op(Opcode::MakeRecord, probe_base, 1, probe_rec, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekGT,
idx_cursor,
probe_rec,
done_label,
P4::None,
0,
);
b.emit_op(Opcode::Goto, 0, outer, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
if needs_table {
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
struct DistinctLooseScan {
index_name: String,
index_root: i32,
/// Number of leading index key terms that equal the DISTINCT output columns
/// (in SELECT order). 1 for `SELECT DISTINCT a`; N for `SELECT DISTINCT a, b, …`
/// served by an index whose leading N terms are exactly those columns.
key_col_count: usize,
}
/// Cheap `Next` attempts per emitted value before the loose scan pays for a
/// root-to-leaf `SeekGT` past the run. Keeps all-distinct indexes at
/// emit-on-change-walk cost instead of one seek per row.
const DISTINCT_LOOSE_SCAN_NEXT_PROBES: usize = 3;
/// Detect `SELECT DISTINCT <col>` resolvable by a loose index scan. Gated tight so the loose scan is
/// provably byte-identical to the sorter path: exactly one plain-column output (not `*`/rowid/expr), a
/// BINARY column with a single-column ASC BINARY index (so index key order == the DISTINCT comparison),
/// not a generated column, and not WITHOUT ROWID. WHERE/GROUP BY/HAVING/LIMIT are excluded by the caller.
/// Searches all indexes so a composite `(col, …)` declared first does not shadow a usable single-column
/// one (bd-agg-range-shadowed-index).
fn distinct_loose_scan_plan(
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<DistinctLooseScan> {
if table.without_rowid || columns.is_empty() {
return None;
}
// Every output must be a plain, non-generated, BINARY column reference. Collect their names in
// SELECT order — they must equal the chosen index's leading key terms in the SAME order, so the
// emitted tuples are exactly the index's leading-prefix groups in index order (byte-identical to
// C SQLite, which walks the same covering index for `SELECT DISTINCT <prefix cols>`).
let mut col_names: Vec<String> = Vec::with_capacity(columns.len());
for rc in columns {
let ResultColumn::Expr { expr, .. } = rc else {
return None;
};
let SortKeySource::Column(col_idx) = resolve_column_ref(expr, table, table_alias)? else {
return None;
};
let column = table.columns.get(col_idx)?;
// BINARY only: the index's BINARY key order must equal the DISTINCT comparison so adjacent-run
// skipping matches C SQLite's grouping. Generated columns decline (first cut).
if column.generated_expr.is_some()
|| column
.collation
.as_deref()
.is_some_and(|c| !c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
col_names.push(column.name.clone());
}
let n = col_names.len();
// The loose scan reads index columns 0..N-1 and probes with an N-field prefix `[v0..vN-1]`, so
// any index whose LEADING N key terms are exactly these columns (same order, each ASC BINARY)
// qualifies — entries sharing that leading tuple (regardless of trailing terms) are all cleared
// by one `SeekGT [v0..vN-1]`. `supports_direct_column_lookup` still excludes partial (WHERE) and
// expression indexes, so the chosen index is a full-coverage plain-column index. Prefer the
// narrowest qualifying index (fewest key terms => smallest entries to scan); this keeps the
// historical single-column choice when one exists and never uses a wider composite unnecessarily.
let idx = table
.indexes
.iter()
.filter(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() >= n
&& (0..n).all(|i| {
!idx.key_term_descending(i)
&& idx
.columns
.get(i)
.is_some_and(|c| c.eq_ignore_ascii_case(&col_names[i]))
&& idx
.key_term_collation(i)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
})
})
.min_by_key(|idx| idx.key_term_count())?;
Some(DistinctLooseScan {
index_name: idx.name.clone(),
index_root: idx.root_page,
key_col_count: n,
})
}
/// Emit `SELECT DISTINCT <col> FROM t` as a loose/skip index scan: open the single-column index, emit
/// the first entry's value, then repeatedly `SeekGT [value]` past the current value's entire duplicate
/// run to the next distinct value. NULL (if present) sorts first and is emitted once like any value.
/// Terminates in ≤ #distinct seeks. The probe is a 1-FIELD prefix `[value]`: `SeekGT`'s index upper-bound
/// (`index_seek_with_bias(UpperBound)`) treats a shorter prefix probe as sorting AFTER every equal-prefix
/// entry (`compare_index_key_values`: `rhs.len() <= lhs.len() => Less`), so one seek clears the whole run.
/// bd-distinct-loose-scan.
#[allow(clippy::unnecessary_wraps)]
fn codegen_select_distinct_loose_scan(
b: &mut ProgramBuilder,
idx_cursor: i32,
scan: &DistinctLooseScan,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
b.emit_op(
Opcode::OpenRead,
idx_cursor,
scan.index_root,
0,
P4::Index(scan.index_name.clone()),
0,
);
let finalize_label = b.emit_label();
// Empty index → nothing to emit.
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, finalize_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_top = b.current_addr() as i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let n = scan.key_col_count as i32;
// Emit the current distinct tuple (index columns 0..N-1), then skip its whole duplicate run.
// The probe copy MUST precede ResultRow: the engine's ResultRow drains its source
// registers (`take_reg_range`), so a Copy placed after it reads NULL and the SeekGT
// probe becomes a constant `[NULL, …]` — the loop then re-emits one row forever (the
// "skip-scan hang" this bead was blocked on).
for i in 0..n {
b.emit_op(Opcode::Column, idx_cursor, i, out_regs + i, P4::None, 0);
}
let probe_base = b.alloc_regs(n);
for i in 0..n {
b.emit_op(Opcode::Copy, out_regs + i, probe_base + i, 0, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
// Adaptive skip (MySQL-style loose scan): try a few cheap Next steps before paying
// for a root-to-leaf SeekGT. Long duplicate runs fall through to the seek (one seek
// clears the whole run); short/unique runs leave via the Ne and never seek, so an
// all-distinct index degrades to an emit-on-change walk instead of one full-height
// seek per row (~11x worse than the sorter when measured). Each Ne carries NULLEQ
// (0x80) + BINARY collation so a NULL run is one distinct value, matching index key
// order; a tuple change on ANY column jumps back to emit the new distinct tuple.
let cur_base = b.alloc_regs(n);
for _ in 0..DISTINCT_LOOSE_SCAN_NEXT_PROBES {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let next_addr = b.current_addr() as i32;
// Next jumps to p2 when a next entry exists; falls through at EOF.
b.emit_op(Opcode::Next, idx_cursor, next_addr + 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
for i in 0..n {
b.emit_op(Opcode::Column, idx_cursor, i, cur_base + i, P4::None, 0);
}
for i in 0..n {
b.emit_op(
Opcode::Ne,
probe_base + i,
loop_top,
cur_base + i,
P4::Collation("BINARY".to_owned()),
0x80,
);
}
}
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_base,
n,
probe_record_reg,
P4::None,
0,
);
// No entry beyond the [v0..vN-1] prefix run → the run was the last one → done.
b.emit_jump_to_label(
Opcode::SeekGT,
idx_cursor,
probe_record_reg,
finalize_label,
P4::None,
0,
);
b.emit_op(Opcode::Goto, 0, loop_top, 0, P4::None, 0);
b.resolve_label(finalize_label);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit `SELECT MIN(col), MAX(col)` over one indexed column as TWO seeks to the two index ends — the
/// MIN end (skipping its NULL region) and the MAX end — instead of a full scan. The cursor is
/// repositioned by the MAX seek, so the two are independent; each feeds its own accumulator exactly as
/// the single-seek path, then both finalize into their SELECT-order output registers. bd-minmax-pair-seek.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_pair_seek(
b: &mut ProgramBuilder,
idx_cursor: i32,
agg_columns: &[AggColumn],
seek: &MinMaxPairSeek,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let min_agg = &agg_columns[seek.min_out_col as usize];
let max_agg = &agg_columns[seek.max_out_col as usize];
let min_out = out_regs + seek.min_out_col;
let max_out = out_regs + seek.max_out_col;
let accum_min = b.alloc_reg();
let accum_max = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_min, 0, P4::None, 0);
b.emit_op(Opcode::Null, 0, accum_max, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
seek.index_root,
0,
P4::Index(seek.index_name.clone()),
0,
);
let arg_reg = b.alloc_reg();
// MIN: from the min end, skip the NULL region toward the values (ASC → Rewind+Next; DESC → Last+Prev).
let (min_end, skip_op) = if seek.descending {
(Opcode::Last, Opcode::Prev)
} else {
(Opcode::Rewind, Opcode::Next)
};
let after_min = b.emit_label();
b.emit_jump_to_label(min_end, idx_cursor, 0, after_min, P4::None, 0);
let min_loop = b.current_addr();
let feed_min = b.emit_label();
b.emit_op(Opcode::Column, idx_cursor, 0, arg_reg, P4::None, 0);
b.emit_jump_to_label(Opcode::NotNull, arg_reg, 0, feed_min, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let min_body = min_loop as i32;
b.emit_op(skip_op, idx_cursor, min_body, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, after_min, P4::None, 0);
b.resolve_label(feed_min);
b.emit_op(
Opcode::AggStep,
0,
arg_reg,
accum_min,
agg_func_p4(&min_agg.name, min_agg.collation.as_ref()),
1,
);
b.resolve_label(after_min);
// MAX: single seek to the max end (ASC → Last; DESC → Rewind). Repositions the cursor.
let max_end = if seek.descending {
Opcode::Rewind
} else {
Opcode::Last
};
let after_max = b.emit_label();
b.emit_jump_to_label(max_end, idx_cursor, 0, after_max, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, arg_reg, P4::None, 0);
b.emit_op(
Opcode::AggStep,
0,
arg_reg,
accum_max,
agg_func_p4(&max_agg.name, max_agg.collation.as_ref()),
1,
);
b.resolve_label(after_max);
// Finalize both into their SELECT-order output registers.
b.emit_op(
Opcode::AggFinal,
accum_min,
min_agg.num_args,
0,
P4::FuncName(min_agg.name.clone()),
0,
);
if accum_min != min_out {
b.emit_op(Opcode::Copy, accum_min, min_out, 0, P4::None, 0);
}
b.emit_op(
Opcode::AggFinal,
accum_max,
max_agg.num_args,
0,
P4::FuncName(max_agg.name.clone()),
0,
);
if accum_max != max_out {
b.emit_op(Opcode::Copy, accum_max, max_out, 0, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit `SELECT MIN(col) WHERE col >[=] c` / `MAX(col) WHERE col <[=] c` as ONE seek to the bound.
///
/// The 1-field probe `[c]` (an integer, matching the INTEGER-affinity column) is `Seek*`'d: `SeekGT`/
/// `SeekGE` lands on the first `col > c` / `>= c` (the min at the bound; NULLs sort first and fail the
/// predicate, so the seek skips them), `SeekLT`/`SeekLE` on the last `col < c` / `<= c` (the max). The
/// single extremum feeds the same AggStep/AggFinal/wrapper sequence the scan uses; an empty match
/// (`Seek*` past the end, or a NULL landed for MAX) leaves the accumulator NULL. bd-minmax-range-seek.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_range_seek(
b: &mut ProgramBuilder,
idx_cursor: i32,
agg_columns: &[AggColumn],
seek: &MinMaxRangeSeek,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let agg = &agg_columns[0];
let accum_reg = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
seek.index_root,
0,
P4::Index(seek.index_name.clone()),
0,
);
let finalize_label = b.emit_label();
// 1-field probe [bound] for the range seek.
let bound_reg = b.alloc_reg();
b.emit_op(Opcode::Int64, 0, bound_reg, 0, P4::Int64(seek.bound), 0);
let probe_rec = b.alloc_reg();
b.emit_op(Opcode::MakeRecord, bound_reg, 1, probe_rec, P4::None, 0);
// Seek to the bound; jump to finalize (accumulator NULL) when no row satisfies it.
b.emit_jump_to_label(
seek.seek_op,
idx_cursor,
probe_rec,
finalize_label,
P4::None,
0,
);
let arg_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, arg_reg, P4::None, 0);
b.emit_op(
Opcode::AggStep,
0,
arg_reg,
accum_reg,
agg_func_p4(&agg.name, agg.collation.as_ref()),
1,
);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
if accum_reg != out_regs {
b.emit_op(Opcode::Copy, accum_reg, out_regs, 0, P4::None, 0);
}
if let Some(wrapper) = &agg.wrapper_expr {
emit_agg_wrapper(b, wrapper, out_regs);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit `SELECT MIN(id) WHERE id >[=] c` / `MAX(id) WHERE id <[=] c` (rowid) as ONE table b-tree seek.
///
/// The rowid is a unique, never-NULL integer, so `Seek*` (a scalar rowid key — no `MakeRecord`) lands
/// directly on the extremum: `SeekGT`/`SeekGE` on the first `id > c` / `>= c`, `SeekLT`/`SeekLE` on the
/// last `id < c` / `<= c`. The single rowid feeds the shared AggStep/AggFinal/wrapper; an empty match
/// (`Seek*` past the end) leaves the accumulator NULL. bd-minmax-rowid-range-seek.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_rowid_range_seek(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
agg_columns: &[AggColumn],
seek_op: Opcode,
bound: i64,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
let agg = &agg_columns[0];
let accum_reg = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
let finalize_label = b.emit_label();
// Scalar rowid key (table b-tree seek — not a record); jump to finalize (NULL) when no row matches.
let bound_reg = b.alloc_reg();
b.emit_op(Opcode::Int64, 0, bound_reg, 0, P4::Int64(bound), 0);
b.emit_jump_to_label(seek_op, cursor, bound_reg, finalize_label, P4::None, 0);
let arg_reg = b.alloc_reg();
b.emit_op(Opcode::Rowid, cursor, arg_reg, 0, P4::None, 0);
b.emit_op(
Opcode::AggStep,
0,
arg_reg,
accum_reg,
agg_func_p4(&agg.name, agg.collation.as_ref()),
1,
);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
if accum_reg != out_regs {
b.emit_op(Opcode::Copy, accum_reg, out_regs, 0, P4::None, 0);
}
if let Some(wrapper) = &agg.wrapper_expr {
emit_agg_wrapper(b, wrapper, out_regs);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Emit the composite-prefix `MIN(b)`/`MAX(b) WHERE a = <const>` seek: one seek to the extremum of
/// `b` within the `a=?` block of a `(a, b, …)` index, instead of scanning the whole group.
///
/// The `a` probe (a partial 1-field key) is coerced to `a`'s affinity, then:
/// - MAX → `SeekLE([a])` lands on the LAST entry of the block; its `b` (index column 1) is the maximum
/// (NULLs sort first, so a NULL there means the whole block's `b` is NULL → MAX = NULL).
/// - MIN → `SeekGE([a])` lands on the FIRST entry; a bounded walk skips leading `b`-NULLs (stopping if
/// `a` changes) to the first non-NULL `b` — the minimum (all-NULL/empty block → MIN = NULL).
///
/// After the seek a verify (`Ne a, target` with JUMPIFNULL) drops to finalize when the block is empty
/// (`SeekLE`/`SeekGE` landed outside it), leaving the accumulator NULL. The single extremum `b` feeds
/// the same `AggStep`/`AggFinal`/wrapper sequence the scan uses, so the result is bit-identical. Only
/// the index cursor is opened (`b` is covered). bd-minmax-prefix-seek.
#[allow(clippy::too_many_arguments, clippy::unnecessary_wraps)]
fn codegen_select_minmax_prefix_seek(
b: &mut ProgramBuilder,
idx_cursor: i32,
agg_columns: &[AggColumn],
seek: &MinMaxPrefixSeek,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
const JUMPIFNULL: u16 = 0x10; // Ne also jumps when either operand is NULL.
let agg = &agg_columns[0];
let accum_reg = b.alloc_reg();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
seek.index_root,
0,
P4::Index(seek.index_name.clone()),
0,
);
let finalize_label = b.emit_label();
// Emit the `a=` target, coerce to `a`'s affinity, and treat `a = NULL` as an empty match.
let a_reg = b.alloc_reg();
emit_expr(b, seek.a_target, a_reg, None);
if let Some(aff) = seek.a_affinity
&& !bound_matches_affinity(aff, seek.a_target)
{
b.emit_op(
Opcode::Affinity,
a_reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, a_reg, 0, finalize_label, P4::None, 0);
// Partial 1-field probe key [a] for the prefix seek.
let probe_rec = b.alloc_reg();
b.emit_op(Opcode::MakeRecord, a_reg, 1, probe_rec, P4::None, 0);
let a_coll = || {
seek.a_collation
.as_deref()
.map_or(P4::None, |c| P4::Collation(c.to_owned()))
};
let cur_a_reg = b.alloc_reg();
let b_reg = b.alloc_reg();
let agg_p4 = agg_func_p4(&agg.name, agg.collation.as_ref());
if seek.is_max {
// MAX is at the block's extremum-`b` end: ASC `b` → last entry (`SeekLE`); DESC `b` → first
// entry (`SeekGE`, O(log n) with no O(block) forward walk). `b` there is the max (NULLs sit at
// the opposite `b` end, so a NULL means the whole block's `b` is NULL → MAX = NULL).
let max_seek = if seek.b_descending {
Opcode::SeekGE
} else {
Opcode::SeekLE
};
b.emit_jump_to_label(max_seek, idx_cursor, probe_rec, finalize_label, P4::None, 0);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_a_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
cur_a_reg,
a_reg,
finalize_label,
a_coll(),
JUMPIFNULL,
);
b.emit_op(Opcode::Column, idx_cursor, 1, b_reg, P4::None, 0);
// fall through to the shared AggStep with `b_reg` = the max `b`.
} else {
// MIN: first entry of the block, then skip leading `b`-NULLs within it.
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_rec,
finalize_label,
P4::None,
0,
);
b.emit_op(Opcode::Column, idx_cursor, 0, cur_a_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
cur_a_reg,
a_reg,
finalize_label,
a_coll(),
JUMPIFNULL,
);
let min_loop = b.current_addr();
b.emit_op(Opcode::Column, idx_cursor, 1, b_reg, P4::None, 0);
let min_found = b.emit_label();
b.emit_jump_to_label(Opcode::NotNull, b_reg, 0, min_found, P4::None, 0);
// `b` is NULL: advance; on EOF fall through to finalize.
let min_advance = b.emit_label();
b.emit_jump_to_label(Opcode::Next, idx_cursor, 0, min_advance, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
b.resolve_label(min_advance);
// Left the `a=?` block? then every `b` in it was NULL -> MIN = NULL.
b.emit_op(Opcode::Column, idx_cursor, 0, cur_a_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
cur_a_reg,
a_reg,
finalize_label,
a_coll(),
JUMPIFNULL,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(Opcode::Goto, 0, min_loop as i32, 0, P4::None, 0);
b.resolve_label(min_found);
// fall through to the shared AggStep with `b_reg` = the min `b`.
}
// Feed the single extremum `b` (the finalize jumps above skip this, leaving the accumulator NULL).
b.emit_op(Opcode::AggStep, 0, b_reg, accum_reg, agg_p4, 1);
b.resolve_label(finalize_label);
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
if accum_reg != out_regs {
b.emit_op(Opcode::Copy, accum_reg, out_regs, 0, P4::None, 0);
}
if let Some(wrapper) = &agg.wrapper_expr {
emit_agg_wrapper(b, wrapper, out_regs);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Generate VDBE bytecode for an aggregate SELECT (no GROUP BY yet).
///
/// Pattern:
/// ```text
/// Init → Transaction → OpenRead → Rewind →
/// [AggStep per aggregate per row] → Next →
/// [AggFinal per aggregate] → ResultRow → Close → Halt
/// ```
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
/// Emit the per-row accumulate body shared by every aggregate row source.
///
/// The caller positions `cursor` on a candidate row and supplies `skip_label`
/// for rows rejected by `WHERE`. This body is row-source agnostic: it reads
/// exclusively through `cursor`, so a full-table scan and an index-driven seek
/// produce byte-identical accumulator state for the same set of rows.
///
/// Can every aggregate be fed from the index entry alone, with no table lookup?
///
/// The position (within the index's key columns) of the table column ordinal `table_col`, if that
/// column is one of the index's key columns. Reading `Column idx_cursor, <this>` yields the value
/// without a table lookup. Only plain (non-expression) key columns match, which is exactly what the
/// direct-lookup indexes these seeks use carry.
fn index_key_position_of(
index: &IndexSchema,
table: &TableSchema,
table_col: usize,
) -> Option<i32> {
index
.columns
.iter()
.position(|name| table.column_index(name) == Some(table_col))
.and_then(|p| i32::try_from(p).ok())
}
/// bd-2dgf5. True when each aggregate is `COUNT(*)` (no argument), takes the rowid (available via
/// `IdxRowid`), or takes ANY of the index's key columns (available directly from the index entry —
/// not just the leading column). Anything that needs a general expression, a `FILTER`, extra
/// arguments, a bare output column, or a non-key table column forces the table lookup.
fn aggregate_seek_is_covering(
agg_columns: &[AggColumn],
index: &IndexSchema,
table: &TableSchema,
) -> bool {
agg_columns.iter().all(|agg| {
// Sentinel entries for multi-aggregate wrappers emit nothing.
if agg.name.is_empty() && !agg.multi_agg_indices.is_empty() {
return true;
}
agg.bare_expr.is_none()
&& agg.filter.is_none()
&& agg.arg_expr.is_none()
&& agg.extra_args.is_empty()
&& (agg.num_args == 0
|| agg.arg_is_rowid
|| agg
.arg_col_index
.is_some_and(|c| index_key_position_of(index, table, c).is_some()))
})
}
/// Covering-index accumulate body: feeds `AggStep` straight from the index
/// entry, so no table cursor is opened and no `SeekRowid` is emitted.
///
/// Only ever reached when [`aggregate_seek_is_covering`] holds, which is why
/// this deliberately handles no expressions, no `FILTER`, and no extra
/// arguments: those shapes take the table-lookup body instead. The `AggStep`
/// opcode, its `distinct` flag, and its P4 are emitted identically to the
/// table-lookup body, so accumulator state is byte-identical for the same rows.
fn emit_aggregate_accumulate_body_covering(
b: &mut ProgramBuilder,
idx_cursor: i32,
index: &IndexSchema,
table: &TableSchema,
agg_columns: &[AggColumn],
accum_base: i32,
) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
if agg.name.is_empty() && !agg.multi_agg_indices.is_empty() {
continue;
}
let accum_reg = accum_base + i as i32;
let distinct_flag = i32::from(agg.distinct);
let agg_p4 = agg_func_p4(&agg.name, agg.collation.as_ref());
if agg.num_args == 0 {
b.emit_op(Opcode::AggStep, distinct_flag, 0, accum_reg, agg_p4, 0);
continue;
}
let arg_base = b.alloc_regs(agg.num_args.max(1));
if agg.arg_is_rowid {
// The index entry carries the rowid; read it without touching the table.
b.emit_op(Opcode::IdxRowid, idx_cursor, arg_base, 0, P4::None, 0);
} else {
let key_pos = agg
.arg_col_index
.and_then(|c| index_key_position_of(index, table, c))
.expect("covering aggregate seek must read one of the index's key columns");
b.emit_op(Opcode::Column, idx_cursor, key_pos, arg_base, P4::None, 0);
}
let num_args = u16::try_from(agg.num_args).unwrap_or_default();
b.emit_op(
Opcode::AggStep,
distinct_flag,
arg_base,
accum_reg,
agg_p4,
num_args,
);
}
}
#[allow(clippy::too_many_arguments)]
fn emit_aggregate_accumulate_body(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
agg_columns: &[AggColumn],
accum_base: i32,
// GH #226: register holding 0 until the first scanned row's bare columns
// have been captured, then 1. Gates row-dependent bare-column stores so an
// aggregate query's bare column keeps the FIRST scanned row (matching stock
// sqlite3) instead of the last. Allocated + zeroed once in the caller's
// shared preamble (before any scan strategy), so a single init serves every
// scan path.
first_row_flag: i32,
) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
// Skip sentinel entries used for multi-aggregate wrappers.
if agg.name.is_empty() && !agg.multi_agg_indices.is_empty() {
continue;
}
let accum_reg = accum_base + i as i32;
// Bare (non-aggregate) column: evaluate expression on the FIRST row only
// and store in the accumulator register. No AggStep needed.
if let Some(ref bare) = agg.bare_expr {
// Row-independent scalars belong to the aggregate output row, not
// an input row. Defer them until finalization so they are evaluated
// exactly once and still have a value for an empty input group.
if !expr_references_scan(bare, table, table_alias) {
continue;
}
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
// GH #226: capture the first scanned row's value; skip on later rows.
let skip_bare = b.emit_label();
b.emit_jump_to_label(Opcode::If, first_row_flag, 0, skip_bare, P4::None, 0);
emit_expr(b, bare, accum_reg, Some(&scan_ctx));
b.resolve_label(skip_bare);
continue;
}
// FILTER clause: evaluate and skip AggStep if false/NULL.
let filter_skip_label = if let Some(ref filter_expr) = agg.filter {
let skip_lbl = b.emit_label();
let filter_reg = b.alloc_temp();
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_expr(b, filter_expr, filter_reg, Some(&scan_ctx));
// p3=1: treat NULL as false (skip AggStep).
b.emit_jump_to_label(Opcode::IfNot, filter_reg, 1, skip_lbl, P4::None, 0);
b.free_temp(filter_reg);
Some(skip_lbl)
} else {
None
};
let distinct_flag = i32::from(agg.distinct);
let agg_p4 = agg_func_p4(&agg.name, agg.collation.as_ref());
if agg.num_args == 0 {
// count(*): no arguments, p2 is unused (0), p5=0.
b.emit_op(
Opcode::AggStep,
distinct_flag,
0,
accum_reg,
agg_p4.clone(),
0,
);
} else {
// Aggregate with arguments: allocate consecutive registers
// for all args so the engine can read them as a contiguous block.
let total_args = agg.num_args.max(1);
// alloc_regs guarantees contiguous register block.
let arg_base = b.alloc_regs(total_args);
// First argument.
if agg.arg_is_rowid {
b.emit_op(Opcode::Rowid, cursor, arg_base, 0, P4::None, 0);
} else if let Some(ref expr) = agg.arg_expr {
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_expr(b, expr, arg_base, Some(&scan_ctx));
} else {
let col_idx = agg.arg_col_index.unwrap_or(0);
// bd-hx3zu (GH#227 sibling): a VIRTUAL generated column passed as
// an aggregate argument on a table scan (no GROUP BY) read its
// NULL placeholder, so sum/avg/min/max/count saw NULLs. Compute
// the generating expression via the canonical reader instead.
if virtual_generated_column_expr(&table.columns[col_idx]).is_some() {
emit_table_column_read(
b,
cursor,
table,
table_alias,
Some(schema),
col_idx,
arg_base,
);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
cursor,
col_idx as i32,
arg_base,
P4::None,
0,
);
}
}
// Extra arguments (e.g. separator for group_concat).
if !agg.extra_args.is_empty() {
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
for (j, extra_expr) in agg.extra_args.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let extra_reg = arg_base + 1 + j as i32;
emit_expr(b, extra_expr, extra_reg, Some(&scan_ctx));
}
}
let num_args = u16::try_from(agg.num_args).unwrap_or_default();
b.emit_op(
Opcode::AggStep,
distinct_flag,
arg_base,
accum_reg,
agg_p4,
num_args,
);
}
// Resolve FILTER skip label after AggStep.
if let Some(skip_lbl) = filter_skip_label {
b.resolve_label(skip_lbl);
}
}
// GH #226: after the first scanned row's bare columns are captured, mark the
// flag so subsequent rows skip the bare-column store (idempotent each row).
b.emit_op(Opcode::Integer, 1, first_row_flag, 0, P4::None, 0);
}
/// The index a `WHERE <equality-prefix>` aggregate can seek instead of scanning.
///
/// bd-2dgf5. The probe record contains only the constrained key prefix. A true
/// partial key is the block floor regardless of the next key term's ASC/DESC
/// direction or whether that term is NULL; appending a synthetic integer floor
/// is not equivalent for either a DESC term or an ASC NULL region.
///
/// Do NOT gate this on an affinity/collation pre-check by analogy with
/// [`index_range_fast_path_is_safe`]. `cmp_p5` is `0x80 | comparison_affinity(..)`,
/// and an `INTEGER` column compared against an integer literal carries a
/// non-zero affinity, so such a gate rejects precisely the queries this seek
/// exists to accelerate and degrades silently to a full scan with no test
/// failure. See `resolved_index_range_comparison_carries_affinity_for_integer_column`.
///
/// Affinity skew is instead handled exactly as the non-aggregate path handles
/// it: the `Ne` compare uses the index's own collation via
/// [`direct_lookup_index_comparison_p4`], and a probe that matches nothing falls
/// back to the full scan through `saw_index_match_reg` rather than trusting an
/// empty result.
///
/// Shared by [`codegen_select_aggregate`] and by the `simple_count_star`
/// suppression in [`codegen_select`], so the two cannot drift apart.
/// Whether an explicit `INDEXED BY` / `NOT INDEXED` hint permits the aggregate
/// index seek.
///
/// `NOT INDEXED` must force the scan, and `INDEXED BY <name>` names an index the
/// seek does not consult, so both decline. Only an unhinted FROM source may seek.
fn aggregate_index_eq_seek_allowed(index_hint: Option<&fsqlite_ast::IndexHint>) -> bool {
index_hint.is_none()
}
fn aggregate_index_eq_seek_target<'t, 'e>(
where_clause: Option<&'e Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
) -> Option<(&'t IndexSchema, Vec<&'e Expr>)> {
// The seek walks the block pinned by an equality PREFIX of an ASC-leading index (`WHERE a=?` on
// `(a,b)`, or `WHERE a=? AND b=?` on `(a,b)`). A partial `[prefix..]` probe anchors at the first
// matching entry even when the next key term is DESC or NULL; a per-term `Ne` stops at the end of
// the run. It applies no residual filter, so it is only correct when the WHERE is EXACTLY the
// pinned equalities: `conjunct_count == prefix_len` declines a query with a leftover predicate the
// seek can't enforce (e.g. `a=? AND b=?` against a shorter `(a)` index would drop `b=?`), letting a
// fuller index win.
let where_expr = where_clause?;
if table.without_rowid {
return None;
}
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
for index in &table.indexes {
if index.key_term_descending(0) || !index.supports_direct_column_lookup() {
continue;
}
let prefix = extract_index_equality_prefix_exprs(index, table, table_alias, where_clause);
if prefix.is_empty() || prefix.len() != conjuncts.len() {
continue;
}
return Some((index, prefix));
}
None
}
/// The index and distinct integer values a `WHERE <col> IN (<int list>)` aggregate can
/// seek per value instead of full-scanning.
///
/// bd-2dgf5. Deliberately narrow so the per-value seek needs no scan fallback:
/// * `col` has INTEGER affinity, so an integer-literal probe is exact — no false-empty
/// that an affinity-coercing scan would otherwise catch.
/// * every list value is an integer literal, and they are de-duplicated, so no two seeks
/// can visit the same duplicate run (double count). Distinct integers are disjoint runs.
/// * single ascending key term (the probe record is one key term + rowid), `IN` not
/// `NOT IN`, non-empty list.
///
/// Anything outside this (text/real/numeric column, a non-integer or non-literal element,
/// `NOT IN`) declines and the aggregate keeps its full scan.
fn index_integer_in_list_target<'t>(
where_clause: Option<&Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
) -> Option<(&'t IndexSchema, Vec<i64>)> {
let (column, ints) = column_int_list_from_predicate(where_clause, table, table_alias)?;
let col_name = column_name(column, table, table_alias)?;
// INTEGER-affinity column only (see the no-fallback argument above).
if table
.column_index(&col_name)
.and_then(|i| table.columns.get(i))?
.affinity
!= 'D'
{
return None;
}
// Prefer a single-column ascending index on the column (its `[value, i64::MIN]` probe is exact).
// `index_for_column` returns the FIRST index whose LEADING column matches, which may be a
// *composite* `(col, …)` index that shadows a usable single-column one listed after it — filtering
// that result would then decline even though a single-column index exists. Search all indexes.
if let Some(idx) = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
}) {
return Some((idx, ints));
}
// bd-in-list-composite-prefix-probe: fall back to a composite index whose ASCENDING leading column
// is the target. The `[value, i64::MIN]` probe is WRONG here — its second field aligns with the
// trailing key column, and a NULL trailing column (sorts before `i64::MIN`) would be skipped by
// SeekGE. `emit_aggregate_index_value_seek` detects the composite case (`key_term_count() > 1`) and
// probes with a 1-field PREFIX `[value]`: SeekGE anchors at the first `a=value` entry regardless of
// the trailing column (including NULL), and the Column-0 run stop is unchanged.
let idx = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() > 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
})?;
Some((idx, ints))
}
/// Returns `(index, ints, has_residual)`. `has_residual == false` is the residual-free case above (the
/// WHOLE WHERE is the IN-list). `has_residual == true` additionally matches an `a IN (<int list>)`
/// CONJUNCT alongside other predicates the caller re-applies as a residual filter. bd-agg-in-list-residual.
fn index_integer_in_list_residual_target<'t>(
where_clause: Option<&Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
) -> Option<(&'t IndexSchema, Vec<i64>, bool)> {
if let Some((idx, ints)) = index_integer_in_list_target(where_clause, table, table_alias) {
return Some((idx, ints, false));
}
let where_expr = where_clause?;
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
if conjuncts.len() < 2 {
return None;
}
for term in &conjuncts {
if let Some((idx, ints)) = index_integer_in_list_target(Some(term), table, table_alias) {
return Some((idx, ints, true));
}
}
None
}
/// The single-column ascending index and range a `SELECT COUNT(*)/SUM(...) FROM t WHERE col <range>`
/// aggregate can seek instead of full-scanning. Reuses the SAME residual-safe extraction and safety
/// gate as the non-aggregate single-column range scan (`extract_column_range_target` +
/// `index_range_fast_path_is_safe`): `extract_column_range_target` matches only when EVERY WHERE
/// conjunct is a range bound on ONE column (an `And` requires both sides to be bounds on the same
/// column, so a residual `c = 5` or a second column declines), so the seek — which applies no
/// residual filter — is byte-exact. Rowid tables only; the trailing sentinel in the probe is the
/// rowid.
/// Returns `(index, range, has_residual)`. `has_residual == false` is the residual-free case above.
/// `has_residual == true` additionally allows a range on an INTEGER-affinity single-column index that
/// coexists with OTHER (placeholder-free) predicates the seek cannot enforce: the integer-literal
/// bounds make the seek a SUPERSET of the matching rows, and the caller re-applies the whole WHERE as a
/// residual filter per row, so it stays byte-exact. Aggregates are order-independent, so visiting a
/// superset and filtering is safe.
fn aggregate_index_range_seek_target<'t, 'e>(
where_clause: Option<&'e Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
) -> Option<(&'t IndexSchema, ColumnRangeTarget<'e>, bool)> {
if table.without_rowid {
return None;
}
let where_expr = where_clause?;
// Residual-free: the WHOLE WHERE is a range on one column.
if let Some((col_name, range)) = extract_column_range_target(where_clause, table, table_alias) {
// Search ALL indexes for a single-column ascending index on `col_name`. `index_for_column`
// returns the FIRST leading-column match, which may be a COMPOSITE `(col, …)` index that
// shadows a usable single-column one declared after it; filtering that result with `?` would
// early-return None and a `COUNT(*) WHERE a <range>` (no residual) would full-scan whenever a
// composite `(a, …)` index is declared before `idx_a` (bd-agg-range-shadowed-index — the same
// shadowing fixed for the IN-list seek). Searching all indexes finds the single-column one.
if let Some(idx) = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
}) {
return index_range_fast_path_is_safe(table, table_alias, schema, &col_name, &range)
.then_some((idx, range, false));
}
}
// Residual: a range on a single-column index plus other predicates. The `bounds_ok` check below
// requires LITERAL bounds, so the probe emits no placeholders and a `?` can appear only in the
// residual, where the filter numbers it identically to the scan path (bd-agg-param-residual).
for index in &table.indexes {
if index.key_term_count() != 1
|| index.key_term_descending(0)
|| !index.supports_direct_column_lookup()
{
continue;
}
let col_name = &index.columns[0];
let affinity = table
.column_index(col_name)
.and_then(|ci| table.columns.get(ci))
.map(|c| c.affinity);
let column_uses_binary_collation = table
.column_index(col_name)
.and_then(|ci| table.columns.get(ci))
.and_then(|column| column.collation.as_deref())
.is_none_or(|collation| collation.eq_ignore_ascii_case("BINARY"));
let Some(range) = extract_named_column_range(where_expr, table, table_alias, col_name)
else {
continue;
};
// The seek is a SUPERSET (no affinity/collation miss) when the bound literals match the
// column's storage class: an INTEGER column + integer literals, or a BINARY-collation TEXT
// column + text literals (a bare `s <op> 'lit'` compares under the column collation, which
// equals the BINARY index collation, so the seek range matches the WHERE range). The residual
// filter narrows to exact.
let bound_is = |b: &ColumnRangeBound<'_>, want_text: bool| {
if want_text {
matches!(b.expr(), Expr::Literal(Literal::String(_), _))
} else {
matches!(b.expr(), Expr::Literal(Literal::Integer(_), _))
}
};
let bounds_ok = match affinity {
Some('D') => {
range.lower.as_ref().is_none_or(|b| bound_is(b, false))
&& range.upper.as_ref().is_none_or(|b| bound_is(b, false))
}
Some('B')
if column_uses_binary_collation
&& index
.key_term_collation(0)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY")) =>
{
range.lower.as_ref().is_none_or(|b| bound_is(b, true))
&& range.upper.as_ref().is_none_or(|b| bound_is(b, true))
}
_ => false,
};
if bounds_ok {
return Some((index, range, true));
}
}
None
}
/// The index and equality prefix a
/// `COUNT(*)/SUM(...) WHERE a = <literal> AND <residual>` aggregate can seek:
/// pin the leading key column(s) with an integer- or text-literal equality,
/// walk that block, and apply the FULL WHERE as a residual filter per row.
/// Gated tight so it is byte-exact and safe: (1) each literal's storage class
/// exactly matches its indexed column's affinity and text probes use BINARY
/// collation, so the seek cannot miss a scan match; (2) the literal probe
/// consumes no placeholders, so bound parameters in the residual keep the
/// same numbering as the scan path; (3) there is at least one residual
/// conjunct beyond the prefix (otherwise the exact eq-seek already handles
/// it). `required_key_term_count` lets callers that depend on rowid ordering
/// reject composite indexes while aggregate callers retain prefix support.
/// The residual filter enforces the whole predicate, so a dropped-predicate
/// class of bug cannot occur.
fn aggregate_index_prefix_literal_residual_target<'t, 'e>(
where_clause: Option<&'e Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
required_key_term_count: Option<usize>,
) -> Option<(&'t IndexSchema, Vec<&'e Expr>)> {
let where_expr = where_clause?;
// A bound parameter is allowed only in the RESIDUAL: the prefix is required to be an integer/text
// LITERAL below, so the probe emits no placeholders and the residual filter numbers `?` exactly as
// the scan path does (bd-agg-param-residual).
if table.without_rowid {
return None;
}
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
for index in &table.indexes {
if index.key_term_descending(0)
|| index.columns.is_empty()
|| index.columns.len() != index.key_term_count()
|| required_key_term_count.is_some_and(|required| index.key_term_count() != required)
|| !index_partial_predicate_is_covered_by_query_conjuncts(
index,
&conjuncts,
table,
table_alias,
)
{
continue;
}
let prefix = extract_index_equality_prefix_exprs(index, table, table_alias, where_clause);
// Need a non-empty prefix AND at least one residual conjunct (else the exact eq-seek owns it).
if prefix.is_empty() || prefix.len() >= conjuncts.len() {
continue;
}
// The seek probe must land on a SUPERSET of the matching rows (the residual filter re-applies
// the whole WHERE and narrows to exact). An integer literal vs an INTEGER column, or a text
// literal vs a TEXT column indexed BINARY, both seek without an affinity/collation miss.
let exact = prefix.iter().enumerate().all(|(i, e)| {
let column = index
.columns
.get(i)
.and_then(|name| table.column_index(name))
.and_then(|ci| table.columns.get(ci));
match e {
Expr::Literal(Literal::Integer(_), _) => {
column.is_some_and(|column| column.affinity == 'D')
}
Expr::Literal(Literal::String(_), _) => {
column.is_some_and(|column| {
column.affinity == 'B'
&& column
.collation
.as_deref()
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
}) && index
.key_term_collation(i)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
}
_ => false,
}
});
if !exact {
continue;
}
return Some((index, prefix));
}
None
}
fn expr_contains_function_call(expr: &Expr) -> bool {
match expr {
// JSON operators are SQL-visible scalar function calls (`->`/`->>`)
// and can be replaced by an application registration. Their child
// expressions are irrelevant once the operator itself is recognized.
Expr::FunctionCall { .. } | Expr::JsonAccess { .. } => true,
Expr::Subquery(select, _)
| Expr::Exists {
subquery: select, ..
} => select_contains_function_call(select),
Expr::BinaryOp { left, right, .. } => {
expr_contains_function_call(left) || expr_contains_function_call(right)
}
Expr::UnaryOp { expr, .. }
| Expr::IsNull { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. } => expr_contains_function_call(expr),
Expr::Between {
expr, low, high, ..
} => {
expr_contains_function_call(expr)
|| expr_contains_function_call(low)
|| expr_contains_function_call(high)
}
Expr::In { expr, set, .. } => {
expr_contains_function_call(expr)
|| match set {
InSet::List(items) => items.iter().any(expr_contains_function_call),
InSet::Subquery(select) => select_contains_function_call(select),
InSet::Table(_) => false,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
expr_contains_function_call(expr)
|| expr_contains_function_call(pattern)
|| escape.as_deref().is_some_and(expr_contains_function_call)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref().is_some_and(expr_contains_function_call)
|| whens.iter().any(|(when_expr, then_expr)| {
expr_contains_function_call(when_expr) || expr_contains_function_call(then_expr)
})
|| else_expr
.as_deref()
.is_some_and(expr_contains_function_call)
}
Expr::RowValue(items, _) => items.iter().any(expr_contains_function_call),
Expr::Literal(_, _)
| Expr::BoundOuterValue { .. }
| Expr::Column(_, _)
| Expr::Raise { .. }
| Expr::Placeholder(_, _) => false,
}
}
fn select_contains_function_call(select: &SelectStatement) -> bool {
select.with.as_ref().is_some_and(|with_clause| {
with_clause
.ctes
.iter()
.any(|cte| select_contains_function_call(&cte.query))
}) || select_core_contains_function_call(&select.body.select)
|| select
.body
.compounds
.iter()
.any(|(_, core)| select_core_contains_function_call(core))
|| select
.order_by
.iter()
.any(|term| expr_contains_function_call(&term.expr))
|| select.limit.as_ref().is_some_and(|clause| {
expr_contains_function_call(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(expr_contains_function_call)
})
}
fn select_core_contains_function_call(core: &SelectCore) -> bool {
match core {
SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
windows,
..
} => {
columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. } if expr_contains_function_call(expr)
)
}) || from.as_ref().is_some_and(from_contains_function_call)
|| where_clause
.as_deref()
.is_some_and(expr_contains_function_call)
|| group_by.iter().any(expr_contains_function_call)
|| having.as_deref().is_some_and(expr_contains_function_call)
|| windows
.iter()
.any(|window| window_spec_contains_function_call(&window.spec))
}
SelectCore::Values(rows) => rows.iter().flatten().any(expr_contains_function_call),
}
}
fn from_contains_function_call(from: &FromClause) -> bool {
table_or_subquery_contains_function_call(&from.source)
|| from.joins.iter().any(|join| {
table_or_subquery_contains_function_call(&join.table)
|| matches!(
&join.constraint,
Some(fsqlite_ast::JoinConstraint::On(expr))
if expr_contains_function_call(expr)
)
})
}
fn table_or_subquery_contains_function_call(source: &TableOrSubquery) -> bool {
match source {
TableOrSubquery::Table { .. } => false,
TableOrSubquery::Subquery { query, .. } => select_contains_function_call(query),
TableOrSubquery::TableFunction { .. } => true,
TableOrSubquery::ParenJoin(from) => from_contains_function_call(from),
}
}
fn window_spec_contains_function_call(spec: &fsqlite_ast::WindowSpec) -> bool {
spec.partition_by.iter().any(expr_contains_function_call)
|| spec
.order_by
.iter()
.any(|term| expr_contains_function_call(&term.expr))
|| spec.frame.as_ref().is_some_and(|frame| {
frame_bound_contains_function_call(&frame.start)
|| frame
.end
.as_ref()
.is_some_and(frame_bound_contains_function_call)
})
}
fn frame_bound_contains_function_call(bound: &fsqlite_ast::FrameBound) -> bool {
match bound {
fsqlite_ast::FrameBound::Preceding(expr) | fsqlite_ast::FrameBound::Following(expr) => {
expr_contains_function_call(expr)
}
fsqlite_ast::FrameBound::UnboundedPreceding
| fsqlite_ast::FrameBound::CurrentRow
| fsqlite_ast::FrameBound::UnboundedFollowing => false,
}
}
/// A partial index is a safe source only when every row that can satisfy the
/// query is present in it. This low-level helper has no affinity proof lattice,
/// so require each partial-predicate conjunct to appear structurally in the
/// query after normalizing only the current table name or alias. An unparseable
/// predicate or foreign qualifier fails closed. A connection with any scalar
/// replacement also declines a function-bearing predicate: structural equality
/// cannot prove that the index's population-time function semantics still match
/// the current registry.
fn index_partial_predicate_is_covered_by_query_conjuncts(
index: &IndexSchema,
query_conjuncts: &[&Expr],
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let Some(predicate_sql) = index.where_clause.as_deref() else {
return true;
};
let Ok(predicate) = parse_sql_expr(predicate_sql) else {
return false;
};
if !use_builtin_scalar_function_semantics() && expr_contains_function_call(&predicate) {
return false;
}
let mut predicate_conjuncts = Vec::new();
collect_conjunctive_terms(&predicate, &mut predicate_conjuncts);
predicate_conjuncts.iter().all(|predicate_conjunct| {
query_conjuncts.iter().any(|query_conjunct| {
expressions_match_table_locally(query_conjunct, predicate_conjunct, table, table_alias)
})
})
}
/// Emit one value's index seek + duplicate-run accumulate for the aggregate IN-list path.
///
/// bd-2dgf5. Opens no cursor itself (the caller opens `idx_cursor`, and `table_cursor` when not
/// covering). A `SeekGE` miss or a first key that is not `value` skips straight to `next_value` (this
/// value contributes nothing). No scan fallback: the INTEGER-affinity + integer-literal gate in
/// [`index_integer_in_list_target`] makes the probe exact, and de-duplicated values keep the runs
/// disjoint. When `covering`, the aggregates read only the indexed column (or are COUNT(*)/SUM(rowid))
/// so each entry accumulates straight from the index — no `IdxRowid`/`SeekRowid` and no table cursor.
#[allow(clippy::too_many_arguments)]
fn emit_aggregate_index_value_seek(
b: &mut ProgramBuilder,
table_cursor: i32,
idx_cursor: i32,
idx_schema: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
agg_columns: &[AggColumn],
accum_base: i32,
// GH #226: forwarded to emit_aggregate_accumulate_body so a bare column
// keeps the first scanned row on this index-value-seek scan path too.
first_row_flag: i32,
value: i64,
covering: bool,
residual_where: Option<&Expr>,
) {
// bd-in-list-composite-prefix-probe: a composite `(a, …)` index is probed with a 1-field PREFIX
// `[value]` so SeekGE anchors at the first `a=value` entry regardless of the trailing key column
// (including a NULL trailing column, which sorts before `i64::MIN`). A single-column index keeps
// the exact 2-field `[value, i64::MIN]` probe (the second field is the rowid floor) — byte-identical.
let prefix_probe = idx_schema.key_term_count() > 1;
let n_probe: i32 = if prefix_probe { 1 } else { 2 };
let probe_key_regs = b.alloc_regs(n_probe);
b.emit_op(Opcode::Int64, 0, probe_key_regs, 0, P4::Int64(value), 0);
if !prefix_probe {
b.emit_op(
Opcode::Int64,
0,
probe_key_regs + 1,
0,
P4::Int64(i64::MIN),
0,
);
}
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
n_probe,
probe_record_reg,
P4::None,
0,
);
let next_value = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
next_value,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let run_top = b.current_addr() as i32;
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs,
idx_key_reg,
next_value,
direct_lookup_index_comparison_p4(idx_schema),
0x10,
);
if covering {
emit_aggregate_accumulate_body_covering(
b,
idx_cursor,
idx_schema,
table,
agg_columns,
accum_base,
);
} else {
let skip_row = b.emit_label();
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
skip_row,
P4::None,
0,
);
if let Some(where_expr) = residual_where {
emit_where_filter(
b,
where_expr,
table_cursor,
table,
table_alias,
schema,
skip_row,
);
}
emit_aggregate_accumulate_body(
b,
table_cursor,
table,
table_alias,
schema,
agg_columns,
accum_base,
first_row_flag,
);
b.resolve_label(skip_row);
}
b.emit_op(Opcode::Next, idx_cursor, run_top, 0, P4::None, 0);
b.resolve_label(next_value);
}
// Takes the same cursor / table / schema / label bundle as every other
// `codegen_select_*` row-source emitter. Bundling those into a struct here alone
// would fork the signature shape it shares with the scan emitter it falls back to.
#[allow(clippy::too_many_arguments)]
fn codegen_select_aggregate(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
having: Option<&Expr>,
limit_clause: Option<&LimitClause>,
limit_anon_placeholder_base: Option<u32>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
allow_index_seek: bool,
// #137 sub-2: the query's FROM index hint. A forced `INDEXED BY <idx>` makes
// the aggregate scan the index in STORED order (so order-sensitive aggregates
// like SUM's transient overflow match stock) instead of the rowid-order table
// scan. `allow_index_seek` is false for a forced hint (it gates the eq-SEEK
// fast paths), so the forced index-ORDERED full scan is enabled from this hint
// directly, orthogonally to `allow_index_seek`.
from_index_hint: Option<&fsqlite_ast::IndexHint>,
) -> Result<(), CodegenError> {
// Resolve SELECT-list aliases referenced from HAVING (e.g.
// `SELECT SUM(qty) AS total_qty ... HAVING total_qty > 100`).
let rewritten_having = having.map(|h| rewrite_having_select_aliases(h, columns, table));
let having = rewritten_having.as_ref();
let limit_is_output_neutral = single_group_aggregate_limit_is_output_neutral(limit_clause);
if where_clause.is_none()
&& having.is_none()
&& limit_is_output_neutral
&& let Some(plan) = simple_count_star_plus_sum_plan(columns, table, table_alias)
{
return codegen_select_count_star_plus_sum(
b, cursor, table, &plan, out_regs, done_label, end_label,
);
}
// Parse aggregate columns: extract function name, arg count, arg column index.
let mut agg_columns = parse_aggregate_columns(columns, table)?;
// Leaf-seek fast path for `MAX(rowid)` / `MIN(rowid)` with no WHERE/HAVING
// (this function is only reached when GROUP BY is empty). Stock SQLite
// special-cases the extremum of the INTEGER PRIMARY KEY as a single seek to
// the rightmost/leftmost leaf (O(log n)) instead of a full B-tree walk.
// The accumulate/finalize/wrapper machinery is reused verbatim so the
// produced result is bit-identical to the full-scan path; only the row that
// feeds AggStep changes (the single extremum row instead of every row).
if where_clause.is_none()
&& having.is_none()
&& limit_is_output_neutral
&& let Some(seek) = minmax_rowid_seek_plan(&agg_columns)
{
return codegen_select_minmax_rowid_seek(
b,
cursor,
table,
&agg_columns,
seek,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// Secondary-index analogue of the rowid MIN/MAX seek: `SELECT MIN(col)`/`MAX(col)` where `col`
// carries a collation-matched ASC index resolves to a single seek to one end of the index
// instead of an O(n) scan (~800x on a 20k-row table). Gated on `allow_index_seek` because it
// opens an index cursor; byte-identical (only the fed row changes). bd-minmax-index-seek.
// MIN/MAX ignore NULLs, so a redundant `WHERE <col> IS NOT NULL` on the aggregate's own column can
// still take the same no-WHERE index-seek fast paths, byte-identically. bd-minmax-redundant-not-null.
let minmax_no_effective_where = where_clause.is_none()
|| minmax_where_is_redundant_not_null(where_clause, &agg_columns, table, table_alias);
if allow_index_seek
&& minmax_no_effective_where
&& having.is_none()
&& limit_is_output_neutral
&& let Some(seek) = minmax_index_seek_plan(&agg_columns, table)
{
return codegen_select_minmax_index_seek(
b,
cursor,
&agg_columns,
&seek,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// `SELECT COUNT(DISTINCT col) FROM t` over a single-column ASC BINARY index walks the index and
// counts key-changes — no ephemeral dedup B-tree, no table read — instead of a full scan that
// deduplicates every row. Byte-identical (BINARY index order == DISTINCT grouping); gated on
// `allow_index_seek`. bd-count-distinct-index-walk.
if allow_index_seek
&& where_clause.is_none()
&& having.is_none()
&& limit_is_output_neutral
&& let Some(walk) = count_distinct_index_walk_plan(&agg_columns, columns, table)
{
codegen_select_count_distinct_index_walk(
b,
cursor,
&walk,
out_regs,
out_col_count,
done_label,
end_label,
);
return Ok(());
}
// `SELECT MIN(col), MAX(col)` over one indexed column: two seeks (one per index end) instead of a
// scan. Byte-identical; gated on `allow_index_seek`. bd-minmax-pair-seek.
if allow_index_seek
&& minmax_no_effective_where
&& having.is_none()
&& limit_is_output_neutral
&& let Some(seek) = minmax_pair_seek_plan(&agg_columns, table)
{
return codegen_select_minmax_pair_seek(
b,
cursor,
&agg_columns,
&seek,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// Composite-prefix analogue: `SELECT MIN(b)/MAX(b) FROM t WHERE a = <const>` on a (a,b,…) index
// seeks to the extremum of `b` within the `a=?` block (one seek) instead of scanning the whole
// group (~800x on a large group). Byte-identical; gated on `allow_index_seek`. bd-minmax-prefix-seek.
if allow_index_seek
&& having.is_none()
&& limit_is_output_neutral
&& let Some(seek) = minmax_prefix_seek_plan(&agg_columns, table, table_alias, where_clause)
{
return codegen_select_minmax_prefix_seek(
b,
cursor,
&agg_columns,
&seek,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// Range-bounded extremum: `SELECT MIN(col) WHERE col >[=] c` / `MAX(col) WHERE col <[=] c` on an
// INTEGER-indexed column seeks the bound (one seek) instead of scanning. bd-minmax-range-seek.
if allow_index_seek
&& having.is_none()
&& limit_is_output_neutral
&& let Some(seek) = minmax_range_seek_plan(&agg_columns, table, table_alias, where_clause)
{
return codegen_select_minmax_range_seek(
b,
cursor,
&agg_columns,
&seek,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// Rowid analogue: `SELECT MIN(id) WHERE id >[=] c` / `MAX(id) WHERE id <[=] c` seeks the table
// b-tree (one seek) instead of scanning the range. bd-minmax-rowid-range-seek.
if allow_index_seek
&& having.is_none()
&& limit_is_output_neutral
&& let Some((seek_op, bound)) =
minmax_rowid_range_seek_plan(&agg_columns, table, table_alias, where_clause)
{
return codegen_select_minmax_rowid_range_seek(
b,
cursor,
table,
&agg_columns,
seek_op,
bound,
out_regs,
out_col_count,
done_label,
end_label,
);
}
// Collect aggregates from the HAVING clause that are not in the SELECT list.
// Without this, HAVING-only aggregates (e.g. `HAVING SUM(x) > 10` when SUM(x)
// is not in SELECT) would never be accumulated and silently evaluate to 0.
let mut having_output_cols: Vec<GroupByOutputCol> = Vec::new();
if let Some(having_expr) = having {
collect_having_aggregates(
having_expr,
table,
&mut agg_columns,
&mut having_output_cols,
);
// GH #225: capture bare columns referenced in HAVING (first scanned row)
// so a HAVING predicate on an unprojected column resolves to a real
// value instead of NULL.
collect_having_bare_columns(having_expr, table, &mut agg_columns);
}
// A single-group aggregate produces at most one row, but LIMIT/OFFSET still
// govern that row. In particular, LIMIT 0 and OFFSET >= 1 must suppress the
// empty-group result just as they suppress a non-empty aggregate result.
let aggregate_anon_placeholder_base = b.current_anon_placeholder();
if let Some(limit_base) = limit_anon_placeholder_base {
// LIMIT appears after the SELECT core, compounds, and ORDER BY in SQL
// text even though its guard is emitted before the scan. Give anonymous
// placeholders their textual indices, then restore the body counter.
b.set_next_anon_placeholder(limit_base);
}
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
b.set_next_anon_placeholder(aggregate_anon_placeholder_base);
// Allocate one accumulator register per aggregate (SELECT + HAVING-only).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let total_agg_count = agg_columns.len() as i32;
let accum_base = b.alloc_regs(total_agg_count);
// Initialize accumulators to Null (required by AggStep protocol).
for i in 0..total_agg_count {
b.emit_op(Opcode::Null, 0, accum_base + i, 0, P4::None, 0);
}
// GH #226: a shared first-row flag for bare-column capture. Zeroed here in
// the single preamble that precedes every scan strategy, so all
// emit_aggregate_accumulate_body call sites gate on the same register and
// the bare column keeps the FIRST scanned row (stock sqlite3), not the last.
let first_row_flag = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, first_row_flag, 0, P4::None, 0);
// bd-2dgf5: indexed-equality seek instead of a full table scan.
//
// `SELECT COUNT(*) FROM t WHERE k = ?` previously walked every row because
// every index access path was disabled for aggregates. When the WHERE
// clause exactly pins an equality prefix of a direct-lookup, ASC-leading
// rowid-table index, drive the accumulate body from an index seek over the
// matching run instead. Only the row source changes; the accumulate body
// and finalize sequence are emitted verbatim, so results are identical.
//
// `aggregate_index_eq_seek_target` admits the path only when every WHERE
// conjunct pins one term of the equality prefix, so the seek enforces the
// whole predicate.
//
// The gate is deliberately identical to the non-aggregate index-seek gate in
// `codegen_select` (`index_eq` + `table.index_for_column`): the seek visits
// exactly the row set that `SELECT * FROM t WHERE col = <const>` already
// visits today, and the aggregate merely accumulates over it. That row-set
// parity, not a separate affinity analysis, is what makes this correct.
//
// Affinity skew is handled the way the non-aggregate path handles it: the
// `Ne` compare below uses the index own collation, and a probe that
// matches nothing falls back to the full scan via `saw_index_match_reg`
// rather than trusting an empty result. An affinity pre-check here would
// silently disable the seek (see `aggregate_index_eq_seek_target`).
//
// Additional aggregate-specific guards:
// * HAVING is not supported on this path.
// * A bare (non-aggregate) output column would expose *which* row the scan
// happened to end on, so the row source must not change.
// * Direct-lookup, ASC-leading indexes only; the partial probe record and
// per-term `Ne` guards handle composite equality prefixes.
let index_eq_seek = if allow_index_seek
&& having.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
aggregate_index_eq_seek_target(where_clause, table, table_alias)
} else {
None
};
// bd-2dgf5: rowid-equality seek instead of a full table scan.
//
// `SELECT COUNT(*)/SUM(v) FROM t WHERE <ipk> = <int literal>` previously walked
// every row because aggregates were gated out of the rowid access path (a rowid
// point lookup is O(log n); the scan is O(n)). This is the rowid analogue of the
// secondary-index seek above and only fires when the seek is provably exact:
// * The RHS must be an INTEGER literal. `SeekRowid` coerces its key via
// `to_integer()`, so `id = 2.5` would truncate to 2 and wrongly match rowid 2,
// and `id = '2'` / a real / a bound param carry affinity the scan handles
// correctly. Everything but an integer literal falls back to the scan.
// * Single-row: rowid is unique, so no duplicate-run loop and no scan fallback are
// needed — a `SeekRowid` miss means zero matches, which finalizes to the same
// COUNT=0 / SUM=NULL the empty scan would produce.
// Mutually exclusive with `index_eq_seek`: the INTEGER PRIMARY KEY is the table
// b-tree key, not a secondary index, so `extract_column_eq_target` never matches it.
let rowid_eq_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
// bd-agg-rowid-eq-coerced: accept any simple constant (placeholder / real /
// text as well as an integer literal), not just an integer literal.
// `extract_rowid_target_expr` already requires `is_simple_constant`; the
// emission coerces a non-integer-literal bound with `MustBeInt` so 2.5 /
// 'abc' / NULL reject to the empty result instead of `SeekRowid` truncating
// them to a wrong rowid. Enables the seek for the prepared `WHERE rowid = ?`.
extract_rowid_target_expr(where_clause, Some(table), table_alias)
} else {
None
};
// bd-2dgf5: rowid-range bounded scan instead of a full table scan.
//
// `SELECT SUM(v) FROM t WHERE <ipk> <= <const>` (and `<`, `>=`, `>`, `BETWEEN`)
// walked every row; a rowid range visits only `[lower, upper]` by positioning with
// `Seek*`/`Rewind` and stopping early once the cursor passes the upper bound. This
// reuses the *exact* extraction, safety gate, and bound-comparison helpers the
// oracle-tested non-aggregate `codegen_select_rowid_range_scan` uses
// (`extract_rowid_range_target` + `rowid_range_fast_path_is_safe` +
// `resolved_rowid_range_comparison`), so the aggregate inherits its affinity/collation
// correctness; only the per-row action changes (accumulate vs `ResultRow`). Ascending
// only — aggregates impose no order.
let rowid_range_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
extract_rowid_range_residual_target(where_clause, table, table_alias)
} else {
None
};
// bd-2dgf5: seek an INTEGER-affinity index once per distinct value of a
// `WHERE col IN (<int literals>)` instead of full-scanning. Narrowly gated so no
// per-value scan fallback is needed (see `index_integer_in_list_target`).
let index_in_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
index_integer_in_list_residual_target(where_clause, table, table_alias)
} else {
None
};
// Seek + bounded walk over a single-column index for `WHERE col <range>` (`>`, `>=`, `<`, `<=`,
// `BETWEEN`) instead of a full scan. Reuses the residual-safe `aggregate_index_range_seek_target`
// detection; mirrors the non-aggregate `codegen_select_index_range_scan`, only the per-row action
// changes (accumulate vs `ResultRow`).
let index_range_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& index_in_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
aggregate_index_range_seek_target(where_clause, table, table_alias, schema)
} else {
None
};
// Seek + bounded walk over a composite index for `WHERE a = v AND b <range>` (equality prefix +
// trailing range). Reuses the now residual-safe `composite_index_prefix_range_target`
// (bd-zqkrp-residual-drop); mirrors the non-aggregate
// `codegen_select_composite_index_prefix_range_scan`. The demoted full-equality case (`a=? AND
// b=?`) is claimed by the earlier `index_eq_seek`, so this only sees genuine ranges.
let composite_prefix_range_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& index_in_seek.is_none()
&& index_range_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
composite_index_prefix_range_target(where_clause, table, table_alias, schema, None)
} else {
None
};
// Seek the equality-prefix block and apply the full WHERE as a residual filter
// per row — for `WHERE a = <literal> AND <residual>` where the residual is not a range the composite
// path handles. The literal prefix needs no bind slot, so the residual filter is emitted once from
// the original WHERE bind base and its Variable opcodes are reused on every row.
let index_prefix_residual_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& index_in_seek.is_none()
&& index_range_seek.is_none()
&& composite_prefix_range_seek.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
aggregate_index_prefix_literal_residual_target(where_clause, table, table_alias, None)
} else {
None
};
// bd-agg-rowid-in: aggregate over `WHERE <rowid> IN (<int literals>)` by SeekRowid per distinct value,
// accumulating each hit, instead of a full scan. The rowid analogue of `index_in_seek` (COUNT(*) is
// served by count_star's own rowid-IN; this serves SUM/AVG/MIN/MAX/COUNT(col)). Gated LAST so no
// existing seek gate changes — a rowid IN is mutually exclusive with every shape above. Integer
// literals only (SeekRowid coerces via to_integer()); sorted+deduped so each row accumulates once; an
// all-miss leaves the accumulators Null (the empty-scan result).
let rowid_in_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& index_in_seek.is_none()
&& index_range_seek.is_none()
&& composite_prefix_range_seek.is_none()
&& index_prefix_residual_seek.is_none()
&& !table.without_rowid
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
extract_rowid_in_list_residual_target(where_clause, table, table_alias)
} else {
None
};
// bd-agg-rowid-eq-residual: aggregate over `WHERE <rowid> = <int> AND <residual>` by seeking the single
// row and re-applying the residual before AggStep. `extract_rowid_eq_residual_target` returns the const
// only when `rowid = const` is a conjunct alongside others (the bare eq is served by rowid_eq_seek).
// Integer literal only; gated after every other seek so no existing gate changes.
let rowid_eq_residual_seek = if allow_index_seek
&& having.is_none()
&& index_eq_seek.is_none()
&& rowid_eq_seek.is_none()
&& rowid_range_seek.is_none()
&& index_in_seek.is_none()
&& index_range_seek.is_none()
&& composite_prefix_range_seek.is_none()
&& index_prefix_residual_seek.is_none()
&& rowid_in_seek.is_none()
&& !table.without_rowid
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
{
extract_rowid_eq_residual_target(where_clause, table, table_alias)
.filter(|rhs| matches!(rhs, Expr::Literal(Literal::Integer(_), _)))
} else {
None
};
let finalize_label = b.emit_label();
let where_placeholder_base = b.current_anon_placeholder();
let mut skip_scan = false;
// #137 sub-2: a forced `INDEXED BY <idx>` on an aggregate must scan the forced
// index in its STORED order, not the rowid-order table scan. Resolve the forced
// index (a plain, non-partial direct-lookup index — an unusable partial forced
// index already errors at prepare per sub-1). NOT gated on `allow_index_seek`
// (false for a forced hint). Covering only when no residual WHERE and no bare
// output column and every aggregate reads from the index entry; otherwise the
// table row is fetched (rowid seek, or a WITHOUT ROWID PK-suffix seek per
// sub-4) and any residual WHERE is re-applied per row.
let forced_index_ordered =
if let Some(fsqlite_ast::IndexHint::IndexedBy(name)) = from_index_hint {
table
.indexes
.iter()
.find(|idx| idx.name.eq_ignore_ascii_case(name))
.filter(|idx| idx.supports_direct_column_lookup())
.map(|forced_idx| {
let covering = where_clause.is_none()
&& agg_columns.iter().all(|agg| agg.bare_expr.is_none())
&& aggregate_seek_is_covering(&agg_columns, forced_idx, table);
(forced_idx, covering)
})
} else {
None
};
if let Some((forced_idx, covering)) = forced_index_ordered {
// Index-ordered full scan feeding the shared accumulate/finalize. Forward
// Rewind/Next = stored order (ascending for an ASC index, descending for a
// DESC index), matching stock's forced-index plan.
let idx_cursor = 1_i32;
if !covering {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
forced_idx.root_page,
0,
P4::Index(forced_idx.name.clone()),
0,
);
// Empty index -> finalize with still-Null accumulators (COUNT=0 / SUM=NULL).
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, finalize_label, P4::None, 0);
let idx_loop_top = b.current_addr();
let idx_skip_label = b.emit_label();
if !covering {
if table.without_rowid {
// WITHOUT ROWID: the index entry carries a PK suffix, not a rowid;
// seek the table b-tree by the PK columns (sub-4).
let pk_indices = without_rowid_pk_indices(table)?;
emit_without_rowid_index_to_table_seek(
b,
table,
cursor,
idx_cursor,
forced_idx,
&pk_indices,
idx_skip_label,
);
} else {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
idx_skip_label,
P4::None,
0,
);
}
// Re-apply the whole WHERE per row (the forced full index scan enforces
// no predicate). Reset the anon-placeholder base so a bound `?` in the
// residual is numbered once, matching the non-index path.
if let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
idx_skip_label,
);
}
}
if covering {
emit_aggregate_accumulate_body_covering(
b,
idx_cursor,
forced_idx,
table,
&agg_columns,
accum_base,
);
} else {
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
}
b.resolve_label(idx_skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
skip_scan = true;
} else if let Some((idx_schema, prefix_exprs)) = index_eq_seek {
let idx_cursor = 1_i32;
let scan_fallback = b.emit_label();
let duplicate_run_done = b.emit_label();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let prefix_len = prefix_exprs.len() as i32;
// Can every aggregate read from the index entry alone? If so the table
// cursor is never opened and no SeekRowid is emitted, matching stock
// SQLite's "USING COVERING INDEX" plan for e.g. `COUNT(*)`/`SUM(rowid)`.
let covering = aggregate_seek_is_covering(&agg_columns, idx_schema, table);
// Every pinned key column is INTEGER-affinity + integer literal ⇒ the seek is EXACT (the index's
// storage-class order matches the WHERE comparison), so a 0-match seek is authoritative and the
// full-scan fallback (an affinity safety net for mixed-type columns) is unnecessary: a miss
// finalizes to the empty-aggregate result (COUNT=0 / SUM=NULL) directly, making an absent-key
// lookup O(log n) not the O(n) fallback scan. (Non-exact keeps the fallback.) bd-eq-seek-fallback-zero-match.
let exact_seek = prefix_exprs.iter().enumerate().all(|(i, e)| {
matches!(e, Expr::Literal(Literal::Integer(_), _))
&& idx_schema
.columns
.get(i)
.and_then(|name| table.column_index(name))
.and_then(|ci| table.columns.get(ci))
.is_some_and(|c| c.affinity == 'D')
});
let seek_miss_label = if exact_seek {
finalize_label
} else {
scan_fallback
};
// A true partial key `[prefix..]` is the floor of the pinned block. Do not append an
// `i64::MIN` sentinel: it sorts at the wrong end of a DESC next term and after NULLs in an ASC
// next term, turning an exact seek into a false miss or silently dropping NULL-bearing rows.
let probe_key_regs = b.alloc_regs(prefix_len);
for (i, expr) in prefix_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe_key_regs + i as i32;
emit_expr(b, expr, reg, None);
// `col = NULL` matches nothing; use the same exact/fallback miss destination as SeekGE.
b.emit_jump_to_label(Opcode::IsNull, reg, 0, seek_miss_label, P4::None, 0);
}
let saw_index_match_reg = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, saw_index_match_reg, 0, P4::None, 0);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
prefix_len,
probe_record_reg,
P4::None,
0,
);
if !covering {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
seek_miss_label,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
let idx_key_reg = b.alloc_reg();
// End the pinned run as soon as any prefix column differs from the probe. `0x10` (JUMPIFNULL):
// a NULL index key also ends the run — it can't equal the non-NULL probe value.
for i in 0..prefix_exprs.len() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let col = i as i32;
b.emit_op(Opcode::Column, idx_cursor, col, idx_key_reg, P4::None, 0);
let coll = idx_schema
.key_term_collation(i)
.filter(|c| !c.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |c| P4::Collation(c.to_owned()));
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs + col,
idx_key_reg,
duplicate_run_done,
coll,
0x10,
);
}
let idx_skip_label = b.emit_label();
b.emit_op(Opcode::Integer, 1, saw_index_match_reg, 0, P4::None, 0);
if !covering {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
idx_skip_label,
P4::None,
0,
);
}
// No WHERE filter here, by construction: `aggregate_index_eq_seek_target`
// only matches when the entire WHERE clause is the pinned equality
// prefix, and the SeekGE + per-term Ne guards enforce exactly those
// predicates. Re-emitting the filter would also consume anonymous
// placeholders a second time and misnumber bound parameters. This
// mirrors `codegen_select_index_equality_scan`, which likewise emits no
// filter on its index path.
if covering {
emit_aggregate_accumulate_body_covering(
b,
idx_cursor,
idx_schema,
table,
&agg_columns,
accum_base,
);
} else {
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
}
b.resolve_label(idx_skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
b.resolve_label(duplicate_run_done);
if exact_seek {
// Exact seek: 0 matches is authoritative → finalize directly (accumulators still Null).
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
} else {
b.emit_jump_to_label(
Opcode::If,
saw_index_match_reg,
0,
finalize_label,
P4::None,
0,
);
}
// No index match: fall through to the full scan. No AggStep ran, so the
// accumulators are still Null and the scan starts from a clean slate.
b.resolve_label(scan_fallback);
b.set_next_anon_placeholder(where_placeholder_base);
} else if let Some(rowid_rhs) = rowid_eq_seek {
// bd-2dgf5 rowid point lookup: seek the single row and accumulate it. A
// `SeekRowid` miss (or a `MustBeInt`-rejected non-exact key) jumps straight to
// finalize, where the still-Null accumulators produce COUNT=0 / SUM=NULL — the
// exact empty-scan result. No duplicate-run loop and no scan fallback: rowid is
// unique and `MustBeInt` (below) makes a non-integer-literal key exact-or-reject,
// so the full scan is skipped.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
let rowid_reg = b.alloc_reg();
emit_expr(b, rowid_rhs, rowid_reg, None);
// bd-agg-rowid-eq-coerced: coerce a non-integer-literal bound (placeholder /
// real / text) to INTEGER affinity before the seek; a non-exact key (2.5 /
// 'abc' / NULL) jumps to finalize (still-Null accumulators -> COUNT=0 /
// SUM=NULL, the exact empty result), while '5' / 5.0 coerce to 5. An integer
// literal is already exact -> no MustBeInt (byte-identical to the pre-existing
// integer-literal callers, golden snapshots unchanged).
if !matches!(rowid_rhs, Expr::Literal(Literal::Integer(_), _)) {
b.emit_jump_to_label(Opcode::MustBeInt, rowid_reg, 0, finalize_label, P4::None, 0);
}
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
finalize_label,
P4::None,
0,
);
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
skip_scan = true;
} else if let Some((range, has_residual)) = rowid_range_seek {
// bd-2dgf5 rowid range: position at the lower bound (or start), accumulate, and
// stop early once the cursor rowid passes the upper bound. Mirrors the ascending
// path of `codegen_select_rowid_range_scan`; a NULL bound yields the empty result
// via a jump to finalize (still-Null accumulators → COUNT=0 / SUM=NULL). With a residual
// predicate the range is a SUPERSET, so the whole (placeholder-free) WHERE is re-applied per row.
let lower_reg = range.lower.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
reg
});
let upper_reg = range.upper.map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
reg
});
let upper_comparison = range
.upper
.map(|bound| resolved_rowid_range_comparison(table, table_alias, schema, bound));
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
if let Some(bound) = range.lower {
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(
seek_opcode,
cursor,
lower_reg.expect("lower bound register exists when range.lower is set"),
finalize_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, finalize_label, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let range_loop_top = b.current_addr() as i32;
if let Some(bound) = range.upper {
let current_rowid_reg = b.alloc_reg();
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_op(Opcode::Rowid, cursor, current_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
stop_opcode,
upper_reg.expect("upper bound register exists when range.upper is set"),
current_rowid_reg,
finalize_label,
upper_comparison
.as_ref()
.map_or(P4::None, |c| c.collation_p4.clone()),
upper_comparison.as_ref().map_or(0, |c| c.cmp_p5),
);
}
let range_skip_label = b.emit_label();
// Residual: re-apply the whole (placeholder-free) WHERE; the rowid range is a superset.
if has_residual && let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
range_skip_label,
);
}
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
b.resolve_label(range_skip_label);
b.emit_op(Opcode::Next, cursor, range_loop_top, 0, P4::None, 0);
skip_scan = true;
} else if let Some((idx_schema, values, has_residual)) = index_in_seek {
// bd-2dgf5 IN-list: open the index (and the table only when not covering), then seek each
// distinct value's duplicate run and accumulate its rows. INTEGER-affinity + integer-literal
// values make each probe exact and the runs disjoint, so no scan fallback is needed. Covering
// when every aggregate reads only the indexed column or is COUNT(*)/SUM(rowid).
let idx_cursor = 1_i32;
let covering = !has_residual && aggregate_seek_is_covering(&agg_columns, idx_schema, table);
let residual_where = has_residual.then_some(where_clause).flatten();
if !covering {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
for value in values {
emit_aggregate_index_value_seek(
b,
cursor,
idx_cursor,
idx_schema,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
value,
covering,
residual_where,
);
}
skip_scan = true;
} else if let Some((idx_schema, index_range, has_residual)) = index_range_seek {
// Single-column index range: position at the lower bound (or `Rewind` when unbounded below),
// walk while the key stays within the range, accumulate. Mirrors the ascending half of
// `codegen_select_index_range_scan`; a NULL bound / empty range jumps to finalize (still-Null
// accumulators → COUNT=0 / SUM=NULL). No ORDER BY / LIMIT to satisfy here. When every aggregate
// reads only the indexed column (or is COUNT(*)/SUM(rowid)) the walk is COVERING — the table
// cursor is never opened and no `SeekRowid` is emitted (SQLite's "USING COVERING INDEX" plan).
// With a residual predicate the walk is a SUPERSET, so it is forced non-covering and the whole
// (placeholder-free) WHERE is re-applied per row.
let idx_cursor = 1_i32;
let covering = !has_residual && aggregate_seek_is_covering(&agg_columns, idx_schema, table);
let bound_affinity = idx_schema
.columns
.first()
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&aff| matches!(aff, 'C' | 'D' | 'E' | 'B'));
let lower_probe = index_range.lower.as_ref().map(|bound| {
let base = b.alloc_regs(2);
emit_expr(b, bound.expr(), base, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, bound.expr())
{
b.emit_op(
Opcode::Affinity,
base,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, base, 0, finalize_label, P4::None, 0);
b.emit_op(Opcode::Int64, 0, base + 1, 0, P4::Int64(i64::MIN), 0);
(base, bound.inclusive)
});
let upper_reg = index_range.upper.as_ref().map(|bound| {
let reg = b.alloc_reg();
emit_expr(b, bound.expr(), reg, None);
if let Some(aff) = bound_affinity
&& !bound_matches_affinity(aff, bound.expr())
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
(reg, bound.inclusive)
});
// A register holding the current key, needed to test an exclusive lower bound or any upper bound.
let current_key_reg = (upper_reg.is_some()
|| lower_probe
.as_ref()
.is_some_and(|(_, inclusive)| !inclusive))
.then(|| b.alloc_reg());
if !covering {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
if let Some((lower_reg, _)) = lower_probe.as_ref() {
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
*lower_reg,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
finalize_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(Opcode::Rewind, idx_cursor, 0, finalize_label, P4::None, 0);
}
let loop_top = b.current_addr();
let skip_label = b.emit_label();
if let Some(key_reg) = current_key_reg {
b.emit_op(Opcode::Column, idx_cursor, 0, key_reg, P4::None, 0);
// With no lower probe (`Rewind` start) the leading NULLs of the index must be skipped —
// a NULL key satisfies no range bound.
if lower_probe.is_none() {
b.emit_jump_to_label(Opcode::IsNull, key_reg, 0, skip_label, P4::None, 0);
}
}
if let Some((lower_reg, inclusive)) = lower_probe.as_ref()
&& !inclusive
{
b.emit_jump_to_label(
Opcode::Le,
*lower_reg,
current_key_reg.expect("exclusive lower bound reads current key"),
skip_label,
P4::None,
0,
);
}
if let Some((up_reg, up_inclusive)) = upper_reg {
let stop = if up_inclusive { Opcode::Gt } else { Opcode::Ge };
b.emit_jump_to_label(
stop,
up_reg,
current_key_reg.expect("upper bound reads current key"),
finalize_label,
P4::None,
0,
);
}
if covering {
emit_aggregate_accumulate_body_covering(
b,
idx_cursor,
idx_schema,
table,
&agg_columns,
accum_base,
);
} else {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
// Residual: re-apply the whole (placeholder-free) WHERE; the range is a superset.
if has_residual && let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, loop_body, 0, P4::None, 0);
skip_scan = true;
} else if let Some(comp) = composite_prefix_range_seek {
// bd-agg-composite-prefix-range: seek the `a = v` block bounded by the range on the next key
// column and accumulate. Mirrors `codegen_select_composite_index_prefix_range_scan`: `IdxGT`
// on the `prefix_len`-column prefix ends the run, the range bound checks skip/stop within it.
// Covering when the aggregates read only the leading (pinned) column or are COUNT(*)/SUM(rowid).
let idx_schema = comp.index;
let prefix_exprs = &comp.prefix_exprs;
let range = &comp.range;
let idx_cursor = 1_i32;
let key_terms = idx_schema.key_term_count();
let prefix_len = prefix_exprs.len();
let range_pos = prefix_len;
let covering = aggregate_seek_is_covering(&agg_columns, idx_schema, table);
let key_affinities: Vec<Option<char>> = (0..key_terms)
.map(|pos| {
idx_schema
.columns
.get(pos)
.and_then(|name| table.column_index(name))
.map(|i| table.columns[i].affinity)
.filter(|&a| matches!(a, 'C' | 'D' | 'E' | 'B'))
})
.collect();
// Probe record: [prefix..., range-lower-or-NULL, trailing NULLs, rowid = MIN].
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let probe = b.alloc_regs(key_terms as i32 + 1);
for (pos, expr) in prefix_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe + pos as i32;
emit_expr(b, expr, reg, None);
if let Some(aff) = key_affinities[pos]
&& !bound_matches_affinity(aff, expr)
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let range_reg = probe + range_pos as i32;
let lower_inclusive = if let Some(lower) = range.lower.as_ref() {
emit_expr(b, lower.expr(), range_reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, lower.expr())
{
b.emit_op(
Opcode::Affinity,
range_reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, range_reg, 0, finalize_label, P4::None, 0);
Some(lower.inclusive)
} else {
b.emit_op(Opcode::Null, 0, range_reg, 0, P4::None, 0);
None
};
for pos in (range_pos + 1)..key_terms {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe + pos as i32;
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let rowid_sentinel = probe + key_terms as i32;
b.emit_op(Opcode::Int64, 0, rowid_sentinel, 0, P4::Int64(i64::MIN), 0);
let probe_rec = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::MakeRecord,
probe,
key_terms as i32 + 1,
probe_rec,
P4::None,
0,
);
let upper = range.upper.as_ref().map(|u| {
let reg = b.alloc_reg();
emit_expr(b, u.expr(), reg, None);
if let Some(aff) = key_affinities[range_pos]
&& !bound_matches_affinity(aff, u.expr())
{
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(aff.to_string()),
0,
);
}
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
(reg, u.inclusive)
});
if !covering {
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
}
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_rec,
finalize_label,
P4::None,
0,
);
let loop_top = b.current_addr();
let skip_label = b.emit_label();
// Stop once the equality prefix changes (`IdxGT` compares only the first `prefix_len` columns).
// With an EMPTY prefix (pure leading-term range, bd-bn45n) there is nothing to compare, so skip
// the IdxGT and let the range bounds alone terminate the walk (mirrors the non-aggregate
// `codegen_select_composite_index_prefix_range_scan`): a zero-column IdxGT is a degenerate no-op.
if prefix_len > 0 {
#[allow(clippy::cast_possible_truncation)]
b.emit_jump_to_label(
Opcode::IdxGT,
idx_cursor,
probe_rec,
finalize_label,
P4::None,
prefix_len as u16,
);
}
let range_key_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
idx_cursor,
range_pos as i32,
range_key_reg,
P4::None,
0,
);
if range.lower.is_none() {
b.emit_jump_to_label(Opcode::IsNull, range_key_reg, 0, skip_label, P4::None, 0);
}
if lower_inclusive == Some(false) {
b.emit_jump_to_label(
Opcode::Le,
range_reg,
range_key_reg,
skip_label,
P4::None,
0,
);
}
if let Some((up_reg, up_inclusive)) = upper {
let stop = if up_inclusive { Opcode::Gt } else { Opcode::Ge };
b.emit_jump_to_label(stop, up_reg, range_key_reg, finalize_label, P4::None, 0);
}
if covering {
emit_aggregate_accumulate_body_covering(
b,
idx_cursor,
idx_schema,
table,
&agg_columns,
accum_base,
);
} else {
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
}
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, loop_body, 0, P4::None, 0);
skip_scan = true;
} else if let Some((idx_schema, prefix_exprs)) = index_prefix_residual_seek {
// bd-agg-leading-eq-residual: seek the storage-class-exact equality-prefix block, then apply
// the full WHERE per row so the residual predicate is enforced (no dropped-predicate bug),
// and accumulate. Always non-covering — the residual filter reads table columns.
let idx_cursor = 1_i32;
let duplicate_run_done = b.emit_label();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let prefix_len = prefix_exprs.len() as i32;
let probe_key_regs = b.alloc_regs(prefix_len);
for (i, expr) in prefix_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let reg = probe_key_regs + i as i32;
emit_expr(b, expr, reg, None);
b.emit_jump_to_label(Opcode::IsNull, reg, 0, finalize_label, P4::None, 0);
}
// A true partial record is the floor of the entire prefix block. A
// synthetic `i64::MIN` next term would sort after NULL on an ASC
// trailing key (and at the wrong end of a DESC key), silently dropping
// matching entries.
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
prefix_len,
probe_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
b.emit_op(
Opcode::OpenRead,
idx_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
b.emit_jump_to_label(
Opcode::SeekGE,
idx_cursor,
probe_record_reg,
finalize_label,
P4::None,
0,
);
let idx_loop_top = b.current_addr();
let idx_key_reg = b.alloc_reg();
for i in 0..prefix_exprs.len() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let col = i as i32;
b.emit_op(Opcode::Column, idx_cursor, col, idx_key_reg, P4::None, 0);
let coll = idx_schema
.key_term_collation(i)
.filter(|c| !c.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |c| P4::Collation(c.to_owned()));
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs + col,
idx_key_reg,
duplicate_run_done,
coll,
0x10,
);
}
let skip_label = b.emit_label();
let rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
// Apply the whole WHERE — the prefix equalities are redundant with the
// seek but harmless; the residual conjuncts are the point.
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_loop_body = idx_loop_top as i32;
b.emit_op(Opcode::Next, idx_cursor, idx_loop_body, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, finalize_label, P4::None, 0);
b.resolve_label(duplicate_run_done);
skip_scan = true;
} else if let Some((values, has_residual)) = rowid_in_seek {
// bd-agg-rowid-in [+ residual]: SeekRowid per distinct listed value, accumulating each hit into the
// aggregate. A miss skips to the next value; an all-miss leaves the accumulators Null (the
// empty-scan result). Mirrors the proven `rowid_eq_seek` block as a per-value loop;
// `emit_aggregate_accumulate_body` reads the seeked row into `AggStep`. Values are sorted+deduped
// so each row accumulates once. With a residual (rowid IN coexisting with a predicate the seek
// cannot enforce) the full WHERE is re-applied per hit — its `?` placeholders re-numbered to a
// fixed base each iteration (the IN values are integer literals, so nothing before the filter
// consumes a placeholder); a residual miss skips to the next value. bd-agg-rowid-in-residual.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
let rowid_reg = b.alloc_reg();
let where_placeholder_base = b.current_anon_placeholder();
for value in &values {
let skip_label = b.emit_label();
b.emit_op(Opcode::Int64, 0, rowid_reg, 0, P4::Int64(*value), 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
if has_residual && let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
b.resolve_label(skip_label);
}
skip_scan = true;
} else if let Some(rowid_rhs) = rowid_eq_residual_seek {
// bd-agg-rowid-eq-residual: seek the single `rowid = <int>` row and re-apply the residual; a
// SeekRowid miss OR a residual miss jumps to finalize (Null accumulators = the empty-scan result).
// The residual is emitted once (single seek), so its `?` placeholders number naturally.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
let rowid_reg = b.alloc_reg();
emit_expr(b, rowid_rhs, rowid_reg, None);
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
finalize_label,
P4::None,
0,
);
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
finalize_label,
);
}
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
skip_scan = true;
}
if !skip_scan {
// Open table for reading.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// Rewind to first row; jump to finalize if table is empty.
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, finalize_label, P4::None, 0);
// WHERE filter.
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
// AggStep for each aggregate column.
emit_aggregate_accumulate_body(
b,
cursor,
table,
table_alias,
schema,
&agg_columns,
accum_base,
first_row_flag,
);
// Skip label for WHERE-filtered rows.
b.resolve_label(skip_label);
// Next: loop back to start of loop body (instruction after Rewind).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (loop_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, loop_body, 0, P4::None, 0);
}
// Finalize: emit AggFinal for each aggregate.
b.resolve_label(finalize_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
// Skip sentinel entries (multi-aggregate wrappers have no function).
if agg.name.is_empty() && !agg.multi_agg_indices.is_empty() {
continue;
}
// Row-dependent bare expressions already hold the final scanned row's
// value. Evaluate row-independent scalars here exactly once so empty
// input still produces their ordinary value.
if let Some(bare) = agg.bare_expr.as_deref() {
let accum_reg = accum_base + i as i32;
if !expr_references_scan(bare, table, table_alias) {
emit_expr(b, bare, accum_reg, None);
} else {
// bd-9zlcs: a scan-referencing bare expression such as
// `typeof(a)` captured its value on the first scanned row
// (gated by `first_row_flag`). Over an EMPTY input group the
// scan never ran, so `accum_reg` still holds its NULL init —
// but stock evaluates the expression against NULL columns
// (e.g. `typeof(a)` over zero rows = typeof(NULL) = 'null',
// not NULL). Emit that empty-group evaluation, guarded so a
// non-empty group keeps its captured value byte-for-byte.
let skip_empty = b.emit_label();
b.emit_jump_to_label(Opcode::If, first_row_flag, 0, skip_empty, P4::None, 0);
let ncols = i32::try_from(table.columns.len()).unwrap_or(0);
if ncols > 0 {
let null_base = b.alloc_regs(ncols);
b.emit_op(
Opcode::Null,
0,
null_base,
null_base + ncols - 1,
P4::None,
0,
);
let null_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: Some(null_base),
secondaries: &[],
};
emit_expr(b, bare, accum_reg, Some(&null_ctx));
} else {
emit_expr(b, bare, accum_reg, None);
}
b.resolve_label(skip_empty);
}
continue;
}
let accum_reg = accum_base + i as i32;
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
}
// Copy accumulator results to output registers, skipping hidden columns.
// For simple (non-multi-agg) cases, out_col_index tracks the output.
{
let mut out_col_index = 0_i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
if agg.hidden {
continue;
}
// Multi-agg sentinel: output slot but no direct accumulator copy.
if agg.name.is_empty() && !agg.multi_agg_indices.is_empty() {
out_col_index += 1;
continue;
}
let accum_reg = accum_base + i as i32;
let out_reg = out_regs + out_col_index;
if accum_reg != out_reg {
b.emit_op(Opcode::Copy, accum_reg, out_reg, 0, P4::None, 0);
}
out_col_index += 1;
}
}
// Apply wrapper expressions after AggFinal.
{
let mut out_col_index = 0_i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
if agg.hidden {
continue;
}
if let Some(wrapper) = &agg.wrapper_expr {
let result_reg = out_regs + out_col_index;
if agg.multi_agg_indices.is_empty() {
// Single-aggregate wrapper (existing path).
emit_agg_wrapper(b, wrapper, result_reg);
} else {
// Multi-aggregate wrapper: build fake table with columns
// for each referenced accumulator.
emit_multi_agg_wrapper(
b,
wrapper,
result_reg,
accum_base,
&agg.multi_agg_indices,
);
}
}
let _ = i; // suppress unused warning
out_col_index += 1;
}
}
// HAVING filter: skip ResultRow if HAVING predicate is false/NULL.
// For single-group aggregate (no GROUP BY), each output column maps
// directly to its aggregate accumulator at accum_base + i.
let output_skip_label = b.emit_label();
if let Some(having_expr) = having {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let output_cols: Vec<GroupByOutputCol> = (0..agg_columns.len())
.map(|i| GroupByOutputCol::Aggregate { agg_index: i })
.collect();
emit_having_filter(
b,
having_expr,
&output_cols,
&agg_columns,
&[],
table,
accum_base,
output_skip_label,
);
}
// OFFSET applies after HAVING. A single-group aggregate can emit only one
// row, so any positive offset consumes that row.
if let Some(offset_reg) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, offset_reg, 1, output_skip_label, P4::None, 0);
}
// ResultRow (reached when HAVING passes or when there is no HAVING).
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
if let Some(limit_reg) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, limit_reg, 0, done_label, P4::None, 0);
}
// Failed HAVING and consumed OFFSET both jump past ResultRow.
b.resolve_label(output_skip_label);
// Done: Close + Halt.
b.resolve_label(done_label);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
Ok(())
}
// Note: codegen_ordered_aggregate (for in-aggregate ORDER BY, SQLite 3.44+)
// was removed because the AggColumn.order_by field is not yet supported.
/// Parse result columns to extract aggregate function metadata.
fn push_bare_aggregate_expr(agg_cols: &mut Vec<AggColumn>, expr: Expr) {
agg_cols.push(AggColumn {
name: String::new(),
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: false,
arg_expr: None,
extra_args: Vec::new(),
filter: None,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: Some(Box::new(expr)),
collation: None,
});
}
fn parse_aggregate_columns(
columns: &[ResultColumn],
table: &TableSchema,
) -> Result<Vec<AggColumn>, CodegenError> {
let mut agg_cols = Vec::with_capacity(columns.len());
for col in columns {
match col {
ResultColumn::Expr {
expr:
Expr::FunctionCall {
name,
args,
distinct,
filter,
..
},
..
} if is_aggregate_function_call(name, args) => {
let canon_name = name.to_ascii_uppercase();
let filt = filter.clone();
match args {
FunctionArgs::Star => {
// count(*)
agg_cols.push(AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
}
FunctionArgs::List(exprs) => {
if exprs.is_empty() {
// count() with no args — treat like count(*)
agg_cols.push(AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
} else {
// First argument: try column reference first,
// fall back to storing the expression for emit_expr.
let (col_idx, is_rowid, expr) =
match resolve_column_ref(&exprs[0], table, None) {
Some(SortKeySource::Column(idx)) => (Some(idx), false, None),
Some(SortKeySource::Rowid) => (None, true, None),
_ => (None, false, Some(Box::new(exprs[0].clone()))),
};
// Resolve collation for the aggregate argument column.
// Explicit COLLATE in the expression takes priority,
// otherwise inherit from the column's schema definition.
let needs_collation =
*distinct || canon_name == "MIN" || canon_name == "MAX";
let agg_coll = if needs_collation {
expr_collation_for_agg(&exprs[0], col_idx, table)
} else {
None
};
// Extra arguments (e.g. separator for group_concat).
let extra: Vec<Expr> = exprs[1..].to_vec();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
agg_cols.push(AggColumn {
name: canon_name,
num_args: exprs.len() as i32,
arg_col_index: col_idx,
arg_is_rowid: is_rowid,
distinct: *distinct,
arg_expr: expr,
extra_args: extra,
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: agg_coll,
});
}
}
}
}
ResultColumn::Expr { expr, .. } if is_aggregate_expr(expr) => {
// Expression wraps aggregate(s). Try single-aggregate
// extraction first (fast path), then multi-aggregate.
let single_ok =
if let Some((inner_agg, ref wrapper)) = extract_inner_aggregate(expr, table) {
if is_aggregate_expr(wrapper) {
// Wrapper still contains aggregates → need multi-agg path.
false
} else {
agg_cols.push(AggColumn {
wrapper_expr: Some(Box::new(wrapper.clone())),
..inner_agg
});
true
}
} else {
false
};
if !single_ok {
// Multi-aggregate: e.g. MAX(x) - MIN(x).
let (extracted, wrapper) = extract_all_inner_aggregates(expr, table);
if extracted.is_empty() {
return Err(CodegenError::Unsupported(
"complex aggregate wrapper expression not supported without GROUP BY"
.to_owned(),
));
}
// Record indices of hidden agg columns for the wrapper.
let base_idx = agg_cols.len();
let indices: Vec<usize> = (base_idx..base_idx + extracted.len()).collect();
// Push hidden aggregates.
agg_cols.extend(extracted);
// Push a sentinel output entry with the multi-agg wrapper.
agg_cols.push(AggColumn {
name: String::new(),
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: false,
arg_expr: None,
extra_args: Vec::new(),
filter: None,
wrapper_expr: Some(Box::new(wrapper)),
hidden: false,
multi_agg_indices: indices,
bare_expr: None,
collation: None,
});
}
}
// Bare (non-aggregate) column in an aggregate query without GROUP BY.
ResultColumn::Expr { expr, .. } => {
push_bare_aggregate_expr(&mut agg_cols, expr.clone());
}
ResultColumn::Star => {
for column in &table.columns {
push_bare_aggregate_expr(
&mut agg_cols,
Expr::Column(ColumnRef::bare(column.name.as_str()), Span::ZERO),
);
}
}
ResultColumn::TableStar(name) => {
for column in &table.columns {
push_bare_aggregate_expr(
&mut agg_cols,
Expr::Column(
ColumnRef::qualified(name.name.as_str(), column.name.as_str()),
Span::ZERO,
),
);
}
}
}
}
Ok(agg_cols)
}
/// Emit bytecode for an aggregate wrapper expression.
///
/// Handles COALESCE/IFNULL patterns: if the aggregate result in `result_reg`
/// is NULL, substitute the first non-NULL fallback literal.
fn emit_agg_wrapper(b: &mut ProgramBuilder, wrapper: &Expr, result_reg: i32) {
let fake_table = TableSchema {
name: "".to_owned(),
root_page: 0,
columns: vec![ColumnInfo {
name: "__agg_result__".to_owned(),
affinity: 'A',
is_ipk: false,
type_name: Some("ANY".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
}],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
};
let scan = ScanCtx {
cursor: 0,
table: &fake_table,
table_alias: None,
schema: None,
register_base: Some(result_reg),
secondaries: &[],
};
let temp = b.alloc_temp();
emit_expr(b, wrapper, temp, Some(&scan));
b.emit_op(Opcode::Copy, temp, result_reg, 0, P4::None, 0);
b.free_temp(temp);
}
/// Emit bytecode for a multi-aggregate wrapper expression.
///
/// Evaluate a simple aggregate wrapper expression (e.g. `COUNT(*) - 1`).
///
/// The wrapper uses a single placeholder column `__agg_result__` that maps
/// to the finalized aggregate value in `accum_reg`.
fn emit_simple_agg_wrapper(
b: &mut ProgramBuilder,
wrapper: &Expr,
result_reg: i32,
accum_reg: i32,
) {
let columns = vec![ColumnInfo {
name: "__agg_result__".to_owned(),
affinity: 'A',
is_ipk: false,
type_name: Some("ANY".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
}];
let fake_table = TableSchema {
name: String::new(),
root_page: 0,
columns,
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
};
let scan = ScanCtx {
cursor: 0,
table: &fake_table,
table_alias: None,
schema: None,
register_base: Some(accum_reg),
secondaries: &[],
};
let temp = b.alloc_temp();
emit_expr(b, wrapper, temp, Some(&scan));
b.emit_op(Opcode::Copy, temp, result_reg, 0, P4::None, 0);
b.free_temp(temp);
}
/// Handles patterns like `MAX(x) - MIN(x)` where the wrapper contains
/// placeholder columns `__agg_0__`, `__agg_1__`, … that map to accumulator
/// registers at `accum_base + multi_agg_indices[N]`.
fn emit_multi_agg_wrapper(
b: &mut ProgramBuilder,
wrapper: &Expr,
result_reg: i32,
accum_base: i32,
multi_agg_indices: &[usize],
) {
// Build a fake table with N columns named `__agg_0__`, `__agg_1__`, …
let columns: Vec<ColumnInfo> = (0..multi_agg_indices.len())
.map(|i| ColumnInfo {
name: format!("__agg_{i}__"),
affinity: 'A',
is_ipk: false,
type_name: Some("ANY".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
})
.collect();
let fake_table = TableSchema {
name: String::new(),
root_page: 0,
columns,
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
};
// Copy accumulators into a contiguous register block so the fake scan
// context can address them via register_base + column_index.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let fake_base = b.alloc_regs(multi_agg_indices.len() as i32);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (j, &agg_idx) in multi_agg_indices.iter().enumerate() {
let src = accum_base + agg_idx as i32;
let dst = fake_base + j as i32;
b.emit_op(Opcode::Copy, src, dst, 0, P4::None, 0);
}
let scan = ScanCtx {
cursor: 0,
table: &fake_table,
table_alias: None,
schema: None,
register_base: Some(fake_base),
secondaries: &[],
};
let temp = b.alloc_temp();
emit_expr(b, wrapper, temp, Some(&scan));
b.emit_op(Opcode::Copy, temp, result_reg, 0, P4::None, 0);
b.free_temp(temp);
}
/// Extract the single aggregate function call from a wrapper expression.
///
/// Returns `(AggColumn, wrapper_expr)` where `wrapper_expr` has the aggregate
/// call replaced with a `ColumnRef` placeholder named `__agg_result__` that
/// the codegen emitter will substitute with the accumulator register.
///
/// Handles patterns like `COALESCE(MAX(x), 0)`, `ABS(SUM(x))`, etc.
fn extract_inner_aggregate(expr: &Expr, table: &TableSchema) -> Option<(AggColumn, Expr)> {
if let Expr::FunctionCall {
name: agg_name,
args: agg_args,
distinct,
filter,
..
} = expr
&& is_aggregate_function_call(agg_name, agg_args)
{
let canon_name = agg_name.to_ascii_uppercase();
let filt = filter.clone();
let agg_col = match agg_args {
FunctionArgs::Star => AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
},
FunctionArgs::List(exprs) if exprs.is_empty() => AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
},
FunctionArgs::List(exprs) => {
let (col_idx, is_rowid, a_expr) = match resolve_column_ref(&exprs[0], table, None) {
Some(SortKeySource::Column(idx)) => (Some(idx), false, None),
Some(SortKeySource::Rowid) => (None, true, None),
_ => (None, false, Some(Box::new(exprs[0].clone()))),
};
let extra: Vec<Expr> = exprs[1..].to_vec();
// bd-lgwjd(a): a single aggregate wrapped in an expression
// (`max(name)||''`) is lowered through THIS extractor; leaving
// `collation: None` made the runtime min/max step compare BINARY,
// ignoring the column's declared `COLLATE NOCASE`. Mirror the
// non-wrapper single-aggregate arm (`expr_collation_for_agg`).
let needs_collation = *distinct || canon_name == "MIN" || canon_name == "MAX";
let agg_coll = if needs_collation {
expr_collation_for_agg(&exprs[0], col_idx, table)
} else {
None
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
AggColumn {
name: canon_name,
num_args: exprs.len() as i32,
arg_col_index: col_idx,
arg_is_rowid: is_rowid,
distinct: *distinct,
arg_expr: a_expr,
extra_args: extra,
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: agg_coll,
}
}
};
let placeholder = Expr::Column(ColumnRef::bare("__agg_result__"), fsqlite_ast::Span::ZERO);
return Some((agg_col, placeholder));
}
match expr {
Expr::FunctionCall {
name,
args: FunctionArgs::List(exprs),
distinct,
order_by,
filter,
over,
span,
} => {
for (i, arg) in exprs.iter().enumerate() {
if let Some((agg_col, new_arg)) = extract_inner_aggregate(arg, table) {
let mut new_exprs = exprs.clone();
new_exprs[i] = new_arg;
return Some((
agg_col,
Expr::FunctionCall {
name: name.clone(),
args: FunctionArgs::List(new_exprs),
distinct: *distinct,
order_by: order_by.clone(),
filter: filter.clone(),
over: over.clone(),
span: *span,
},
));
}
}
}
Expr::BinaryOp {
left,
op,
right,
span,
} => {
if let Some((agg_col, new_left)) = extract_inner_aggregate(left, table) {
return Some((
agg_col,
Expr::BinaryOp {
left: Box::new(new_left),
op: *op,
right: right.clone(),
span: *span,
},
));
}
if let Some((agg_col, new_right)) = extract_inner_aggregate(right, table) {
return Some((
agg_col,
Expr::BinaryOp {
left: left.clone(),
op: *op,
right: Box::new(new_right),
span: *span,
},
));
}
}
Expr::UnaryOp {
op,
expr: inner,
span,
} => {
if let Some((agg_col, new_inner)) = extract_inner_aggregate(inner, table) {
return Some((
agg_col,
Expr::UnaryOp {
op: *op,
expr: Box::new(new_inner),
span: *span,
},
));
}
}
Expr::Case {
operand,
whens,
else_expr,
span,
} => {
if let Some(b) = operand
&& let Some((agg_col, new_base)) = extract_inner_aggregate(b, table)
{
return Some((
agg_col,
Expr::Case {
operand: Some(Box::new(new_base)),
whens: whens.clone(),
else_expr: else_expr.clone(),
span: *span,
},
));
}
for (i, (cond, val)) in whens.iter().enumerate() {
if let Some((agg_col, new_cond)) = extract_inner_aggregate(cond, table) {
let mut new_whens = whens.clone();
new_whens[i].0 = new_cond;
return Some((
agg_col,
Expr::Case {
operand: operand.clone(),
whens: new_whens,
else_expr: else_expr.clone(),
span: *span,
},
));
}
if let Some((agg_col, new_val)) = extract_inner_aggregate(val, table) {
let mut new_whens = whens.clone();
new_whens[i].1 = new_val;
return Some((
agg_col,
Expr::Case {
operand: operand.clone(),
whens: new_whens,
else_expr: else_expr.clone(),
span: *span,
},
));
}
}
if let Some(e) = else_expr
&& let Some((agg_col, new_else)) = extract_inner_aggregate(e, table)
{
return Some((
agg_col,
Expr::Case {
operand: operand.clone(),
whens: whens.clone(),
else_expr: Some(Box::new(new_else)),
span: *span,
},
));
}
}
Expr::IsNull {
expr: inner,
not,
span,
} => {
if let Some((agg_col, new_inner)) = extract_inner_aggregate(inner, table) {
return Some((
agg_col,
Expr::IsNull {
expr: Box::new(new_inner),
not: *not,
span: *span,
},
));
}
}
Expr::Cast {
expr: inner,
type_name,
span,
} => {
if let Some((agg_col, new_inner)) = extract_inner_aggregate(inner, table) {
return Some((
agg_col,
Expr::Cast {
expr: Box::new(new_inner),
type_name: type_name.clone(),
span: *span,
},
));
}
}
Expr::Collate {
expr: inner,
collation,
span,
} => {
if let Some((agg_col, new_inner)) = extract_inner_aggregate(inner, table) {
return Some((
agg_col,
Expr::Collate {
expr: Box::new(new_inner),
collation: collation.clone(),
span: *span,
},
));
}
}
_ => {}
}
None
}
/// Extract ALL aggregate function calls from an expression, replacing each
/// with a numbered placeholder `__agg_N__`. Returns the list of extracted
/// aggregates and the rewritten wrapper expression.
///
/// Used for expressions like `MAX(x) - MIN(x)` that contain multiple
/// aggregate calls.
fn extract_all_inner_aggregates(expr: &Expr, table: &TableSchema) -> (Vec<AggColumn>, Expr) {
let mut agg_cols = Vec::new();
let rewritten = rewrite_aggregates_recursive(expr, table, &mut agg_cols);
(agg_cols, rewritten)
}
/// Recursively rewrite an expression, replacing each aggregate function call
/// with a `ColumnRef::bare("__agg_N__")` placeholder and collecting the
/// corresponding `AggColumn`.
fn rewrite_aggregates_recursive(
expr: &Expr,
table: &TableSchema,
agg_cols: &mut Vec<AggColumn>,
) -> Expr {
// If this node IS an aggregate function call, extract it entirely.
if let Expr::FunctionCall {
name,
args,
distinct,
filter,
..
} = expr
&& is_aggregate_function_call(name, args)
{
let idx = agg_cols.len();
let canon_name = name.to_ascii_uppercase();
let filt = filter.clone();
let agg_col = match args {
FunctionArgs::Star => AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: true,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
},
FunctionArgs::List(exprs) if exprs.is_empty() => AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: true,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
},
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
FunctionArgs::List(exprs) => {
let (col_idx, is_rowid, a_expr) = match resolve_column_ref(&exprs[0], table, None) {
Some(SortKeySource::Column(i)) => (Some(i), false, None),
Some(SortKeySource::Rowid) => (None, true, None),
_ => (None, false, Some(Box::new(exprs[0].clone()))),
};
let extra: Vec<Expr> = exprs[1..].to_vec();
// bd-lgwjd(a): resolve the aggregate-argument collation for MIN/MAX
// (and DISTINCT) here too — a multi-aggregate expression like
// `max(name)||min(name)` is lowered through THIS extractor, and
// leaving `collation: None` made the runtime min/max step compare
// BINARY, so a column's declared `COLLATE NOCASE` was ignored. The
// single-aggregate arm already does this (see `needs_collation`
// above); mirror it so the extracted aggregate carries the same
// collation.
let needs_collation = *distinct || canon_name == "MIN" || canon_name == "MAX";
let agg_coll = if needs_collation {
expr_collation_for_agg(&exprs[0], col_idx, table)
} else {
None
};
AggColumn {
name: canon_name,
num_args: exprs.len() as i32,
arg_col_index: col_idx,
arg_is_rowid: is_rowid,
distinct: *distinct,
arg_expr: a_expr,
extra_args: extra,
filter: filt,
wrapper_expr: None,
hidden: true,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: agg_coll,
}
}
};
agg_cols.push(agg_col);
let placeholder_name = format!("__agg_{idx}__");
return Expr::Column(
ColumnRef::bare(placeholder_name.as_str()),
fsqlite_ast::Span::ZERO,
);
}
// Recurse into child nodes.
match expr {
Expr::BinaryOp {
left,
op,
right,
span,
} => Expr::BinaryOp {
left: Box::new(rewrite_aggregates_recursive(left, table, agg_cols)),
op: *op,
right: Box::new(rewrite_aggregates_recursive(right, table, agg_cols)),
span: *span,
},
Expr::UnaryOp {
op,
expr: inner,
span,
} => Expr::UnaryOp {
op: *op,
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
span: *span,
},
Expr::Between {
expr: inner,
low,
high,
not,
span,
} => Expr::Between {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
low: Box::new(rewrite_aggregates_recursive(low, table, agg_cols)),
high: Box::new(rewrite_aggregates_recursive(high, table, agg_cols)),
not: *not,
span: *span,
},
Expr::In {
expr: inner,
set,
not,
span,
} => {
let rewritten_set = match set {
InSet::List(items) => InSet::List(
items
.iter()
.map(|item| rewrite_aggregates_recursive(item, table, agg_cols))
.collect(),
),
other => other.clone(),
};
Expr::In {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
set: rewritten_set,
not: *not,
span: *span,
}
}
Expr::Like {
expr: inner,
pattern,
escape,
op,
not,
span,
} => Expr::Like {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
pattern: Box::new(rewrite_aggregates_recursive(pattern, table, agg_cols)),
escape: escape
.as_deref()
.map(|expr| Box::new(rewrite_aggregates_recursive(expr, table, agg_cols))),
op: *op,
not: *not,
span: *span,
},
Expr::Case {
operand,
whens,
else_expr,
span,
} => Expr::Case {
operand: operand
.as_deref()
.map(|expr| Box::new(rewrite_aggregates_recursive(expr, table, agg_cols))),
whens: whens
.iter()
.map(|(when_expr, then_expr)| {
(
rewrite_aggregates_recursive(when_expr, table, agg_cols),
rewrite_aggregates_recursive(then_expr, table, agg_cols),
)
})
.collect(),
else_expr: else_expr
.as_deref()
.map(|expr| Box::new(rewrite_aggregates_recursive(expr, table, agg_cols))),
span: *span,
},
Expr::FunctionCall {
name,
args: FunctionArgs::List(exprs),
distinct,
order_by,
filter,
over,
span,
} => {
let new_exprs: Vec<Expr> = exprs
.iter()
.map(|e| rewrite_aggregates_recursive(e, table, agg_cols))
.collect();
Expr::FunctionCall {
name: name.clone(),
args: FunctionArgs::List(new_exprs),
distinct: *distinct,
order_by: order_by.clone(),
filter: filter.clone(),
over: over.clone(),
span: *span,
}
}
Expr::Cast {
expr: inner,
type_name,
span,
} => Expr::Cast {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
type_name: type_name.clone(),
span: *span,
},
Expr::Collate {
expr: inner,
collation,
span,
} => Expr::Collate {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
collation: collation.clone(),
span: *span,
},
Expr::IsNull {
expr: inner,
not,
span,
} => Expr::IsNull {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
not: *not,
span: *span,
},
Expr::JsonAccess {
expr: inner,
path,
arrow,
span,
} => Expr::JsonAccess {
expr: Box::new(rewrite_aggregates_recursive(inner, table, agg_cols)),
path: Box::new(rewrite_aggregates_recursive(path, table, agg_cols)),
arrow: *arrow,
span: *span,
},
Expr::RowValue(items, span) => Expr::RowValue(
items
.iter()
.map(|item| rewrite_aggregates_recursive(item, table, agg_cols))
.collect(),
*span,
),
// For all other expression types, return as-is (no aggregates inside).
other => other.clone(),
}
}
// ---------------------------------------------------------------------------
// GROUP BY aggregate codegen
// ---------------------------------------------------------------------------
/// A GROUP BY key that is either a simple column reference or an arbitrary
/// expression (e.g. `length(name)`, `substr(city, 1, 1)`).
#[derive(Debug)]
enum GroupByKey {
/// Direct table column — read via `Opcode::Column`.
Column(usize),
/// Arbitrary expression — evaluated via `emit_expr` during the scan phase.
Expression(Box<Expr>),
}
/// Describes one output column in a GROUP BY query.
enum GroupByOutputCol {
/// A GROUP BY key column. `key_index` is the position within the group key
/// vector, and `sorter_col` is the column index in the sorter record.
GroupKey {
#[allow(dead_code)]
key_index: usize,
sorter_col: usize,
},
/// An aggregate function column. `agg_index` is the position within the
/// aggregate accumulator vector.
Aggregate { agg_index: usize },
/// A non-grouped column from `SELECT *`. SQLite allows non-grouped,
/// non-aggregated columns in GROUP BY queries, returning an arbitrary
/// row's value. `table_col_index` is the column index in the table;
/// `sorter_col` is the column index in the sorter record; `is_ipk` is
/// true if this is an INTEGER PRIMARY KEY (rowid alias).
NonGroupedColumn {
table_col_index: usize,
sorter_col: usize,
is_ipk: bool,
},
}
/// Parse result columns for a GROUP BY query into output-column descriptors,
/// a list of group keys (column refs or expressions), and aggregate metadata.
///
/// Returns `(output_cols, group_by_keys, agg_columns)`.
#[allow(clippy::type_complexity)]
fn parse_group_by_output(
columns: &[ResultColumn],
table: &TableSchema,
group_by: &[Expr],
) -> Result<(Vec<GroupByOutputCol>, Vec<GroupByKey>, Vec<AggColumn>), CodegenError> {
// Resolve GROUP BY expressions: column references become Column(idx),
// arbitrary expressions (e.g. length(name)) become Expression(expr).
let group_by_keys: Vec<GroupByKey> = group_by
.iter()
.map(|expr| {
if let Some(col_idx) = resolve_column_index(expr, table) {
GroupByKey::Column(col_idx)
} else {
GroupByKey::Expression(Box::new(expr.clone()))
}
})
.collect();
let mut output_cols = Vec::with_capacity(columns.len());
let mut agg_columns = Vec::with_capacity(columns.len());
for col in columns {
match col {
ResultColumn::Expr {
expr:
Expr::FunctionCall {
name,
args,
distinct,
filter,
..
},
..
} if is_aggregate_function_call(name, args) => {
let agg_index = agg_columns.len();
let canon_name = name.to_ascii_uppercase();
let filt = filter.clone();
match args {
FunctionArgs::Star => {
agg_columns.push(AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
}
FunctionArgs::List(exprs) => {
if exprs.is_empty() {
agg_columns.push(AggColumn {
name: canon_name,
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
} else {
// Try column reference first, fall back to expression.
let (col_idx, is_rowid, expr) =
match resolve_column_ref(&exprs[0], table, None) {
Some(SortKeySource::Column(idx)) => (Some(idx), false, None),
Some(SortKeySource::Rowid) => (None, true, None),
_ => (None, false, Some(Box::new(exprs[0].clone()))),
};
let extra: Vec<Expr> = exprs[1..].to_vec();
// bd-lgwjd(a): a direct MIN/MAX under GROUP BY is lowered
// through this substrate path; carry the argument's
// declared collation so the runtime step compares under it
// (not BINARY), mirroring the other aggregate extractors.
let needs_collation =
*distinct || canon_name == "MIN" || canon_name == "MAX";
let agg_coll = if needs_collation {
expr_collation_for_agg(&exprs[0], col_idx, table)
} else {
None
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
agg_columns.push(AggColumn {
name: canon_name,
num_args: exprs.len() as i32,
arg_col_index: col_idx,
arg_is_rowid: is_rowid,
distinct: *distinct,
arg_expr: expr,
extra_args: extra,
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: agg_coll,
});
}
}
}
output_cols.push(GroupByOutputCol::Aggregate { agg_index });
}
ResultColumn::Expr { expr, .. } => {
// Match result column to a GROUP BY key: try column index
// first, then structural expression equality.
let key_index = if let Some(col_idx) = resolve_column_index(expr, table) {
group_by_keys
.iter()
.position(|k| matches!(k, GroupByKey::Column(c) if *c == col_idx))
} else {
group_by_keys
.iter()
.position(|k| matches!(k, GroupByKey::Expression(e) if e.as_ref() == expr))
}
.ok_or_else(|| {
CodegenError::Unsupported("result column not in GROUP BY clause".to_owned())
})?;
output_cols.push(GroupByOutputCol::GroupKey {
key_index,
sorter_col: key_index,
});
}
ResultColumn::Star | ResultColumn::TableStar(_) => {
// Expand * to all table columns. Each column is either a
// GROUP BY key or a non-grouped column (SQLite allows this,
// returning an arbitrary row's value for non-grouped cols).
for (col_idx, col_info) in table.columns.iter().enumerate() {
if let Some(key_index) = group_by_keys
.iter()
.position(|k| matches!(k, GroupByKey::Column(c) if *c == col_idx))
{
output_cols.push(GroupByOutputCol::GroupKey {
key_index,
sorter_col: key_index,
});
} else {
// Non-grouped column — sorter_col assigned later
// in codegen_select_group_by_aggregate.
output_cols.push(GroupByOutputCol::NonGroupedColumn {
table_col_index: col_idx,
sorter_col: usize::MAX,
is_ipk: col_info.is_ipk,
});
}
}
}
}
}
Ok((output_cols, group_by_keys, agg_columns))
}
/// Rewrite a HAVING clause so that bare references to SELECT-list output
/// aliases are replaced by the underlying SELECT expression.
///
/// C SQLite resolves a name in HAVING against the FROM tables first, and only
/// when it is not a real table column does it bind to a SELECT-list alias. For
/// example `SELECT SUM(qty) AS total_qty ... HAVING total_qty > 100` means
/// `HAVING SUM(qty) > 100`. Without this rewrite the bare `total_qty` reference
/// resolves to neither a table column nor a group key and silently becomes
/// NULL, dropping every row. We deliberately keep table columns taking
/// precedence over aliases to match SQLite's binding order.
fn rewrite_having_select_aliases(
expr: &Expr,
columns: &[ResultColumn],
table: &TableSchema,
) -> Expr {
rewrite_having_select_aliases_inner(expr, columns, table, &mut Vec::new())
}
fn rewrite_having_select_aliases_inner(
expr: &Expr,
columns: &[ResultColumn],
table: &TableSchema,
active_aliases: &mut Vec<String>,
) -> Expr {
match expr {
Expr::Column(col_ref, _) if col_ref.table.is_none() => {
let name = col_ref.column.as_ref();
// A real table column always wins over a SELECT alias.
if table.column_index(name).is_some() || table.resolves_to_hidden_rowid(name) {
return expr.clone();
}
// Find a SELECT-list column whose alias matches (case-insensitive,
// like SQLite identifier matching).
for col in columns {
if let ResultColumn::Expr {
expr: select_expr,
alias: Some(alias),
} = col
&& alias.eq_ignore_ascii_case(name)
{
if active_aliases
.iter()
.any(|active| active.eq_ignore_ascii_case(alias))
{
return expr.clone();
}
// Substitute the underlying SELECT expression, then keep
// walking it for further nested aliases.
active_aliases.push(alias.clone());
let rewritten = rewrite_having_select_aliases_inner(
select_expr,
columns,
table,
active_aliases,
);
active_aliases.pop();
return rewritten;
}
}
expr.clone()
}
Expr::BinaryOp {
left,
op,
right,
span,
} => Expr::BinaryOp {
left: Box::new(rewrite_having_select_aliases_inner(
left,
columns,
table,
active_aliases,
)),
op: *op,
right: Box::new(rewrite_having_select_aliases_inner(
right,
columns,
table,
active_aliases,
)),
span: *span,
},
Expr::UnaryOp { op, expr, span } => Expr::UnaryOp {
op: *op,
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
span: *span,
},
Expr::Between {
expr,
low,
high,
not,
span,
} => Expr::Between {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
low: Box::new(rewrite_having_select_aliases_inner(
low,
columns,
table,
active_aliases,
)),
high: Box::new(rewrite_having_select_aliases_inner(
high,
columns,
table,
active_aliases,
)),
not: *not,
span: *span,
},
Expr::In {
expr,
set,
not,
span,
} => Expr::In {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
set: rewrite_having_in_set_aliases(set, columns, table, active_aliases),
not: *not,
span: *span,
},
Expr::Like {
expr,
pattern,
escape,
op,
not,
span,
} => Expr::Like {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
pattern: Box::new(rewrite_having_select_aliases_inner(
pattern,
columns,
table,
active_aliases,
)),
escape: escape.as_ref().map(|escape| {
Box::new(rewrite_having_select_aliases_inner(
escape,
columns,
table,
active_aliases,
))
}),
op: *op,
not: *not,
span: *span,
},
Expr::IsNull { expr, not, span } => Expr::IsNull {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
not: *not,
span: *span,
},
Expr::Collate {
expr,
collation,
span,
} => Expr::Collate {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
collation: collation.clone(),
span: *span,
},
Expr::Cast {
expr,
type_name,
span,
} => Expr::Cast {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
type_name: type_name.clone(),
span: *span,
},
Expr::Case {
operand,
whens,
else_expr,
span,
} => Expr::Case {
operand: operand.as_ref().map(|o| {
Box::new(rewrite_having_select_aliases_inner(
o,
columns,
table,
active_aliases,
))
}),
whens: whens
.iter()
.map(|(w, t)| {
(
rewrite_having_select_aliases_inner(w, columns, table, active_aliases),
rewrite_having_select_aliases_inner(t, columns, table, active_aliases),
)
})
.collect(),
else_expr: else_expr.as_ref().map(|e| {
Box::new(rewrite_having_select_aliases_inner(
e,
columns,
table,
active_aliases,
))
}),
span: *span,
},
Expr::FunctionCall {
name,
args,
distinct,
order_by,
filter,
over,
span,
} if !is_aggregate_function_call(name, args) => Expr::FunctionCall {
name: name.clone(),
args: rewrite_having_function_args_aliases(args, columns, table, active_aliases),
distinct: *distinct,
order_by: rewrite_having_ordering_aliases(order_by, columns, table, active_aliases),
filter: filter.as_ref().map(|filter| {
Box::new(rewrite_having_select_aliases_inner(
filter,
columns,
table,
active_aliases,
))
}),
over: over.clone(),
span: *span,
},
Expr::JsonAccess {
expr,
path,
arrow,
span,
} => Expr::JsonAccess {
expr: Box::new(rewrite_having_select_aliases_inner(
expr,
columns,
table,
active_aliases,
)),
path: Box::new(rewrite_having_select_aliases_inner(
path,
columns,
table,
active_aliases,
)),
arrow: *arrow,
span: *span,
},
Expr::RowValue(values, span) => Expr::RowValue(
values
.iter()
.map(|value| {
rewrite_having_select_aliases_inner(value, columns, table, active_aliases)
})
.collect(),
*span,
),
// Do not rewrite inside aggregate calls or subqueries. Aggregate
// arguments and FILTER predicates bind within the aggregate's input-row
// scope, and subqueries own their own SELECT-list alias scope.
_ => expr.clone(),
}
}
fn rewrite_having_in_set_aliases(
set: &InSet,
columns: &[ResultColumn],
table: &TableSchema,
active_aliases: &mut Vec<String>,
) -> InSet {
match set {
InSet::List(values) => InSet::List(
values
.iter()
.map(|value| {
rewrite_having_select_aliases_inner(value, columns, table, active_aliases)
})
.collect(),
),
InSet::Subquery(_) | InSet::Table(_) => set.clone(),
}
}
fn rewrite_having_function_args_aliases(
args: &FunctionArgs,
columns: &[ResultColumn],
table: &TableSchema,
active_aliases: &mut Vec<String>,
) -> FunctionArgs {
match args {
FunctionArgs::Star => FunctionArgs::Star,
FunctionArgs::List(args) => FunctionArgs::List(
args.iter()
.map(|arg| rewrite_having_select_aliases_inner(arg, columns, table, active_aliases))
.collect(),
),
}
}
fn rewrite_having_ordering_aliases(
terms: &[OrderingTerm],
columns: &[ResultColumn],
table: &TableSchema,
active_aliases: &mut Vec<String>,
) -> Vec<OrderingTerm> {
terms
.iter()
.map(|term| OrderingTerm {
expr: rewrite_having_select_aliases_inner(&term.expr, columns, table, active_aliases),
direction: term.direction,
nulls: term.nulls,
})
.collect()
}
/// Walk a HAVING expression to find aggregate function calls and add any that
/// are not already present in `agg_columns` / `output_cols`. This ensures that
/// aggregates referenced only in HAVING (not in the SELECT list) still get
/// accumulator slots and `AggStep`/`AggFinal` instructions.
fn collect_having_aggregates(
expr: &Expr,
table: &TableSchema,
agg_columns: &mut Vec<AggColumn>,
output_cols: &mut Vec<GroupByOutputCol>,
) {
match expr {
Expr::FunctionCall {
name,
args,
distinct,
filter,
..
} if is_aggregate_function_call(name, args) => {
let upper = name.to_ascii_uppercase();
// Check if this aggregate already exists in agg_columns.
let already_exists = agg_columns.iter().any(|agg| {
if agg.name != upper || agg.distinct != *distinct {
return false;
}
match args {
FunctionArgs::Star => agg.num_args == 0,
FunctionArgs::List(exprs) => {
if exprs.is_empty() {
return agg.num_args == 0;
}
if let Some(ci) = resolve_column_index(&exprs[0], table) {
agg.arg_col_index == Some(ci)
} else if let Some(ref arg_expr) = agg.arg_expr {
exprs.len() == 1 && **arg_expr == exprs[0]
} else {
false
}
}
}
});
if !already_exists {
let agg_index = agg_columns.len();
let filt = filter.clone();
match args {
FunctionArgs::Star => {
agg_columns.push(AggColumn {
name: upper.clone(),
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
}
FunctionArgs::List(exprs) => {
if exprs.is_empty() {
agg_columns.push(AggColumn {
name: upper.clone(),
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: *distinct,
arg_expr: None,
extra_args: Vec::new(),
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
} else {
let (col_idx, is_rowid, arg_e) =
match resolve_column_ref(&exprs[0], table, None) {
Some(SortKeySource::Column(idx)) => (Some(idx), false, None),
Some(SortKeySource::Rowid) => (None, true, None),
_ => (None, false, Some(Box::new(exprs[0].clone()))),
};
let extra: Vec<Expr> = exprs[1..].to_vec();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
agg_columns.push(AggColumn {
name: upper,
num_args: exprs.len() as i32,
arg_col_index: col_idx,
arg_is_rowid: is_rowid,
distinct: *distinct,
arg_expr: arg_e,
extra_args: extra,
filter: filt,
wrapper_expr: None,
hidden: false,
multi_agg_indices: Vec::new(),
bare_expr: None,
collation: None,
});
}
}
}
output_cols.push(GroupByOutputCol::Aggregate { agg_index });
}
}
// Recurse into sub-expressions to find nested aggregates.
Expr::BinaryOp { left, right, .. } => {
collect_having_aggregates(left, table, agg_columns, output_cols);
collect_having_aggregates(right, table, agg_columns, output_cols);
}
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Collate { expr: inner, .. }
| Expr::Cast { expr: inner, .. } => {
collect_having_aggregates(inner, table, agg_columns, output_cols);
}
Expr::Between {
expr: inner,
low,
high,
..
} => {
collect_having_aggregates(inner, table, agg_columns, output_cols);
collect_having_aggregates(low, table, agg_columns, output_cols);
collect_having_aggregates(high, table, agg_columns, output_cols);
}
Expr::In {
expr: inner, set, ..
} => {
collect_having_aggregates(inner, table, agg_columns, output_cols);
if let InSet::List(values) = set {
for value in values {
collect_having_aggregates(value, table, agg_columns, output_cols);
}
}
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
collect_having_aggregates(inner, table, agg_columns, output_cols);
collect_having_aggregates(pattern, table, agg_columns, output_cols);
if let Some(escape) = escape {
collect_having_aggregates(escape, table, agg_columns, output_cols);
}
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
if let Some(operand) = operand {
collect_having_aggregates(operand, table, agg_columns, output_cols);
}
for (when_expr, then_expr) in whens {
collect_having_aggregates(when_expr, table, agg_columns, output_cols);
collect_having_aggregates(then_expr, table, agg_columns, output_cols);
}
if let Some(else_expr) = else_expr {
collect_having_aggregates(else_expr, table, agg_columns, output_cols);
}
}
Expr::FunctionCall {
args,
filter,
order_by,
..
} => {
if let FunctionArgs::List(args) = args {
for arg in args {
collect_having_aggregates(arg, table, agg_columns, output_cols);
}
}
if let Some(filter) = filter {
collect_having_aggregates(filter, table, agg_columns, output_cols);
}
for term in order_by {
collect_having_aggregates(&term.expr, table, agg_columns, output_cols);
}
}
Expr::JsonAccess {
expr: inner, path, ..
} => {
collect_having_aggregates(inner, table, agg_columns, output_cols);
collect_having_aggregates(path, table, agg_columns, output_cols);
}
Expr::RowValue(values, _) => {
for value in values {
collect_having_aggregates(value, table, agg_columns, output_cols);
}
}
_ => {}
}
}
/// GH #225: append every bare table column referenced by a HAVING clause OUTSIDE
/// any aggregate as a HIDDEN `bare_expr` aggregate, so the implicit-aggregate
/// scan captures the FIRST scanned row's value for it (matching stock sqlite3's
/// bare-column-in-HAVING semantics). `emit_having_expr` then resolves the column
/// to this aggregate's accumulator instead of falling back to NULL. Columns
/// already covered by a `bare_expr` aggregate (e.g. also in the SELECT list) are
/// not duplicated. Aggregate arguments are intentionally NOT descended into —
/// those belong to the aggregate, not a bare column.
fn collect_having_bare_columns(expr: &Expr, table: &TableSchema, agg_columns: &mut Vec<AggColumn>) {
match expr {
// A column referenced directly in HAVING: capture it (first row).
Expr::Column(_, _) => {
let Some(ci) = resolve_column_index(expr, table) else {
return;
};
let already = agg_columns.iter().any(|agg| {
agg.bare_expr
.as_deref()
.and_then(|be| resolve_column_index(be, table))
== Some(ci)
});
if !already {
agg_columns.push(AggColumn {
name: String::new(),
num_args: 0,
arg_col_index: None,
arg_is_rowid: false,
distinct: false,
arg_expr: None,
extra_args: Vec::new(),
filter: None,
wrapper_expr: None,
hidden: true,
multi_agg_indices: Vec::new(),
bare_expr: Some(Box::new(expr.clone())),
collation: None,
});
}
}
// Do not descend into an aggregate's own arguments.
Expr::FunctionCall { name, args, .. } if is_aggregate_function_call(name, args) => {}
Expr::BinaryOp { left, right, .. } => {
collect_having_bare_columns(left, table, agg_columns);
collect_having_bare_columns(right, table, agg_columns);
}
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Collate { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::In { expr: inner, .. } => {
collect_having_bare_columns(inner, table, agg_columns);
}
Expr::Between {
expr: inner,
low,
high,
..
} => {
collect_having_bare_columns(inner, table, agg_columns);
collect_having_bare_columns(low, table, agg_columns);
collect_having_bare_columns(high, table, agg_columns);
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
collect_having_bare_columns(inner, table, agg_columns);
collect_having_bare_columns(pattern, table, agg_columns);
if let Some(escape) = escape.as_deref() {
collect_having_bare_columns(escape, table, agg_columns);
}
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
if let Some(operand) = operand.as_deref() {
collect_having_bare_columns(operand, table, agg_columns);
}
for (when_expr, then_expr) in whens {
collect_having_bare_columns(when_expr, table, agg_columns);
collect_having_bare_columns(then_expr, table, agg_columns);
}
if let Some(else_expr) = else_expr.as_deref() {
collect_having_bare_columns(else_expr, table, agg_columns);
}
}
// Non-aggregate function calls may carry bare columns in their arguments.
Expr::FunctionCall {
args: FunctionArgs::List(list),
..
} => {
for arg in list {
collect_having_bare_columns(arg, table, agg_columns);
}
}
_ => {}
}
}
/// Generate VDBE bytecode for an aggregate SELECT **with GROUP BY**.
///
/// Two-pass pattern:
/// 1. Scan table rows (with WHERE), pack group-key + agg-arg columns into sorter.
/// 2. After sorting, iterate sorted rows detecting group boundaries via key
/// comparison. On each boundary, finalize accumulators and emit `ResultRow`.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn codegen_select_group_by_aggregate(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
columns: &[ResultColumn],
where_clause: Option<&Expr>,
group_by: &[Expr],
having: Option<&Expr>,
limit_clause: Option<&LimitClause>,
out_regs: i32,
out_col_count: i32,
done_label: crate::Label,
end_label: crate::Label,
) -> Result<(), CodegenError> {
// Resolve SELECT-list aliases referenced from HAVING (e.g.
// `SELECT product_id, SUM(qty) AS total_qty ... HAVING total_qty >= 10`).
let rewritten_having = having.map(|h| rewrite_having_select_aliases(h, columns, table));
let having = rewritten_having.as_ref();
if where_clause.is_none()
&& having.is_none()
&& limit_clause.is_none()
&& let Some(plan) =
simple_group_by_rowid_bucket_sum_plan(columns, table, table_alias, group_by)
{
return codegen_select_group_by_rowid_bucket_sum(
b, cursor, table, &plan, out_regs, done_label, end_label,
);
}
let (mut output_cols, group_by_keys, mut agg_columns) =
parse_group_by_output(columns, table, group_by)?;
// Collect aggregates from the HAVING clause that are not in the SELECT list.
if let Some(having_expr) = having {
collect_having_aggregates(having_expr, table, &mut agg_columns, &mut output_cols);
}
let (limit_reg, offset_reg) = emit_limit_offset_registers(b, limit_clause, done_label);
let num_group_keys = group_by_keys.len();
let num_aggs = agg_columns.len();
// Collect unique table-column indices needed for aggregate arguments.
let mut agg_arg_table_cols: Vec<usize> = Vec::new();
for agg in &agg_columns {
if let Some(ci) = agg.arg_col_index
&& !agg_arg_table_cols.contains(&ci)
{
agg_arg_table_cols.push(ci);
}
}
// Count expression-arg aggregates (each gets its own sorter slot).
let num_expr_args = agg_columns.iter().filter(|a| a.arg_expr.is_some()).count();
// Rowid-argument aggregates (e.g. SUM(rowid)) need one shared sorter slot.
let needs_rowid = agg_columns.iter().any(|a| a.arg_is_rowid);
let num_rowid_slots: usize = usize::from(needs_rowid);
// Count aggregates with FILTER clauses (each gets a boolean sorter slot).
let num_filter_cols = agg_columns.iter().filter(|a| a.filter.is_some()).count();
// Count non-grouped columns (from SELECT * expansion) and assign sorter slots.
let num_nongrouped = output_cols
.iter()
.filter(|c| matches!(c, GroupByOutputCol::NonGroupedColumn { .. }))
.count();
let nongrouped_start = num_group_keys
+ agg_arg_table_cols.len()
+ num_expr_args
+ num_rowid_slots
+ num_filter_cols;
let mut next_nongrouped_slot = nongrouped_start;
for col in &mut output_cols {
if let GroupByOutputCol::NonGroupedColumn { sorter_col, .. } = col {
*sorter_col = next_nongrouped_slot;
next_nongrouped_slot += 1;
}
}
// Sorter layout: [group_keys..., col_args..., expr_args..., rowid_slot?, filter_bools..., nongrouped_cols...]
let total_sorter_cols = num_group_keys
+ agg_arg_table_cols.len()
+ num_expr_args
+ num_rowid_slots
+ num_filter_cols
+ num_nongrouped;
// Map each aggregate's arg to its sorter column index.
let mut agg_sorter_col: Vec<Option<usize>> = Vec::with_capacity(agg_columns.len());
let mut next_expr_slot = num_group_keys + agg_arg_table_cols.len();
let rowid_slot = num_group_keys + agg_arg_table_cols.len() + num_expr_args;
for agg in &agg_columns {
let sorter_col = if agg.arg_expr.is_some() {
let slot = next_expr_slot;
next_expr_slot += 1;
Some(slot)
} else if agg.arg_is_rowid {
Some(rowid_slot)
} else if let Some(ci) = agg.arg_col_index {
let Some(pos) = agg_arg_table_cols.iter().position(|&x| x == ci) else {
return Err(CodegenError::Unsupported(
"internal: aggregate argument column missing from sorter layout".to_owned(),
));
};
Some(num_group_keys + pos)
} else {
None
};
agg_sorter_col.push(sorter_col);
}
// Map each FILTER-bearing aggregate to its boolean sorter column.
let mut filter_sorter_col: Vec<Option<usize>> = Vec::with_capacity(agg_columns.len());
let mut next_filter_slot =
num_group_keys + agg_arg_table_cols.len() + num_expr_args + num_rowid_slots;
for agg in &agg_columns {
if agg.filter.is_some() {
filter_sorter_col.push(Some(next_filter_slot));
next_filter_slot += 1;
} else {
filter_sorter_col.push(None);
}
}
// Sorter cursor.
let sorter_cursor = cursor + 1;
// Open sorter: p2 = number of key columns (for sorting by group keys).
let sort_order: String = std::iter::repeat_n('+', num_group_keys).collect();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
num_group_keys as i32,
0,
P4::Str(sort_order),
0,
);
// Open table for reading.
b.emit_op(
Opcode::OpenRead,
cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// === Pass 1: Scan rows into sorter ===
let scan_start = b.current_addr();
let scan_done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, cursor, 0, scan_done, P4::None, 0);
// WHERE filter.
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
cursor,
table,
table_alias,
schema,
skip_label,
);
}
// Read group-key values + agg-arg columns into consecutive registers.
// For column-based keys, use Opcode::Column; for expression-based keys,
// evaluate the expression via emit_expr.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let sorter_base = b.alloc_regs(total_sorter_cols as i32);
{
let scan_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
let mut reg = sorter_base;
for key in &group_by_keys {
match key {
GroupByKey::Column(col_idx) => {
// bd-hx3zu (GH#227 sibling): a VIRTUAL generated column is not
// materialized in the record — reading it raw here grouped on
// the NULL placeholder. Route it through the canonical reader
// so the generating expression is computed on read, exactly
// as single-table projection / emit_join_expr do.
if virtual_generated_column_expr(&table.columns[*col_idx]).is_some() {
emit_table_column_read(
b,
cursor,
table,
table_alias,
Some(schema),
*col_idx,
reg,
);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(Opcode::Column, cursor, *col_idx as i32, reg, P4::None, 0);
}
}
GroupByKey::Expression(expr) => {
emit_expr(b, expr, reg, Some(&scan_ctx));
}
}
reg += 1;
}
for &col_idx in &agg_arg_table_cols {
// bd-hx3zu (GH#227 sibling): compute a VIRTUAL generated column used
// as an aggregate argument instead of reading its NULL placeholder.
if virtual_generated_column_expr(&table.columns[col_idx]).is_some() {
emit_table_column_read(b, cursor, table, table_alias, Some(schema), col_idx, reg);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(Opcode::Column, cursor, col_idx as i32, reg, P4::None, 0);
}
reg += 1;
}
// Expression-arg aggregates: evaluate each expression into its sorter slot.
for agg in &agg_columns {
if let Some(ref expr) = agg.arg_expr {
emit_expr(b, expr, reg, Some(&scan_ctx));
reg += 1;
}
}
// Rowid slot: store rowid if any aggregate references it (e.g. SUM(rowid)).
if needs_rowid {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
reg += 1;
}
// FILTER clause booleans: evaluate each filter and store 0/1 in sorter.
for agg in &agg_columns {
if let Some(ref filter_expr) = agg.filter {
emit_expr(b, filter_expr, reg, Some(&scan_ctx));
reg += 1;
}
}
// Non-grouped columns (from SELECT * expansion): store in sorter.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for col in &output_cols {
if let GroupByOutputCol::NonGroupedColumn {
table_col_index,
is_ipk,
..
} = col
{
if *is_ipk {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
cursor,
*table_col_index as i32,
reg,
P4::None,
0,
);
}
reg += 1;
}
}
}
// MakeRecord + SorterInsert.
let record_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::MakeRecord,
sorter_base,
total_sorter_cols as i32,
record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sorter_cursor,
record_reg,
0,
P4::None,
0,
);
// Skip label (for WHERE-filtered rows).
b.resolve_label(skip_label);
// Next row in scan.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let scan_body = (scan_start + 1) as i32;
b.emit_op(Opcode::Next, cursor, scan_body, 0, P4::None, 0);
// End of pass 1.
b.resolve_label(scan_done);
b.emit_op(Opcode::Close, cursor, 0, 0, P4::None, 0);
// === Pass 2: Iterate sorted rows, accumulate per-group ===
// Allocate registers for current group keys, previous group keys, accumulators,
// and non-grouped column values.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let cur_key_base = b.alloc_regs(num_group_keys as i32);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let prev_key_base = b.alloc_regs(num_group_keys as i32);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let accum_base = b.alloc_regs(num_aggs as i32);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let prev_nongrouped_base = b.alloc_regs(num_nongrouped.max(1) as i32);
let first_flag = b.alloc_reg();
// Initialize: first_flag = 1, accumulators = Null.
b.emit_op(Opcode::Integer, 1, first_flag, 0, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_aggs as i32 {
b.emit_op(Opcode::Null, 0, accum_base + i, 0, P4::None, 0);
}
// SorterSort: sort and position at first row; jump to done if empty.
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
done_label,
P4::None,
0,
);
let sort_loop_body = b.current_addr();
// SorterData: decode current sorted row.
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
// Read group-key columns from sorter into cur_key registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_group_keys {
b.emit_op(
Opcode::Column,
sorter_cursor,
i as i32,
cur_key_base + i as i32,
P4::None,
0,
);
}
// If first row, skip group-change comparison.
let first_row_label = b.emit_label();
b.emit_jump_to_label(Opcode::IfPos, first_flag, 1, first_row_label, P4::None, 0);
// Compare current keys to previous keys. If any differ, jump to new_group.
let new_group_label = b.emit_label();
let same_group_label = b.emit_label();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_group_keys {
// Ne p1=cur_key, p2=new_group_label, p3=prev_key, p5=0x80 (NULLEQ)
b.emit_jump_to_label(
Opcode::Ne,
cur_key_base + i as i32,
prev_key_base + i as i32,
new_group_label,
P4::None,
0x80,
);
}
// All keys match — same group.
b.emit_jump_to_label(Opcode::Goto, 0, 0, same_group_label, P4::None, 0);
// new_group: finalize previous group and output ResultRow.
b.resolve_label(new_group_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
let accum_reg = accum_base + i as i32;
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
}
// Build output row from prev_key + accum + prev_nongrouped.
// Only iterate the SELECT output columns (first out_col_count entries);
// HAVING-only aggregates appended by collect_having_aggregates are
// accumulated and used by emit_having_filter, but NOT included in the
// output row.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
{
let mut ng_idx = 0i32;
for (i, out_col) in output_cols.iter().take(out_col_count as usize).enumerate() {
match out_col {
GroupByOutputCol::GroupKey { sorter_col, .. } => {
b.emit_op(
Opcode::Copy,
prev_key_base + *sorter_col as i32,
out_regs + i as i32,
0,
P4::None,
0,
);
}
GroupByOutputCol::Aggregate { agg_index } => {
b.emit_op(
Opcode::Copy,
accum_base + *agg_index as i32,
out_regs + i as i32,
0,
P4::None,
0,
);
}
GroupByOutputCol::NonGroupedColumn { .. } => {
b.emit_op(
Opcode::Copy,
prev_nongrouped_base + ng_idx,
out_regs + i as i32,
0,
P4::None,
0,
);
ng_idx += 1;
}
}
}
}
// HAVING filter: skip this group's output if HAVING predicate is false.
let having_skip_label = b.emit_label();
if let Some(having_expr) = having {
emit_having_filter(
b,
having_expr,
&output_cols,
&agg_columns,
&group_by_keys,
table,
out_regs,
having_skip_label,
);
}
// OFFSET: if offset counter > 0, skip this group's output.
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, having_skip_label, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
// LIMIT: decrement limit counter; jump to done when exhausted.
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, done_label, P4::None, 0);
}
b.resolve_label(having_skip_label);
// Reset accumulators for next group.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_aggs as i32 {
b.emit_op(Opcode::Null, 0, accum_base + i, 0, P4::None, 0);
}
// first_row: (jumped here when first_flag was 1, skipping comparison).
b.resolve_label(first_row_label);
// same_group: copy current keys to previous, then AggStep.
b.resolve_label(same_group_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for i in 0..num_group_keys {
b.emit_op(
Opcode::Copy,
cur_key_base + i as i32,
prev_key_base + i as i32,
0,
P4::None,
0,
);
}
// Copy non-grouped columns from sorter to prev_nongrouped registers.
// These hold the latest (arbitrary) value for each non-grouped column.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
{
let mut ng_idx = 0i32;
for col in &output_cols {
if let GroupByOutputCol::NonGroupedColumn { sorter_col, .. } = col {
b.emit_op(
Opcode::Column,
sorter_cursor,
*sorter_col as i32,
prev_nongrouped_base + ng_idx,
P4::None,
0,
);
ng_idx += 1;
}
}
}
// AggStep for each aggregate.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
let accum_reg = accum_base + i as i32;
// FILTER clause: read boolean from sorter and skip AggStep if false/NULL.
let filter_skip_label = if let Some(filt_col) = filter_sorter_col[i] {
let skip_lbl = b.emit_label();
let filt_reg = b.alloc_temp();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
sorter_cursor,
filt_col as i32,
filt_reg,
P4::None,
0,
);
// p3=1: treat NULL as false (skip AggStep).
b.emit_jump_to_label(Opcode::IfNot, filt_reg, 1, skip_lbl, P4::None, 0);
b.free_temp(filt_reg);
Some(skip_lbl)
} else {
None
};
let distinct_flag = i32::from(agg.distinct);
if agg.num_args == 0 {
// count(*): no arguments.
b.emit_op(
Opcode::AggStep,
distinct_flag,
0,
accum_reg,
agg_func_p4(&agg.name, agg.collation.as_ref()),
0,
);
} else {
let total_args = agg.num_args.max(1);
let arg_base = b.alloc_regs(total_args);
let Some(sorter_col) = agg_sorter_col[i] else {
return Err(CodegenError::Unsupported(
"internal: non-zero-arg aggregate missing sorter column".to_owned(),
));
};
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
sorter_cursor,
sorter_col as i32,
arg_base,
P4::None,
0,
);
// Extra arguments (e.g. separator for group_concat):
// re-evaluate inline since they are typically constant expressions.
for (j, extra_expr) in agg.extra_args.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let extra_reg = arg_base + 1 + j as i32;
emit_expr(b, extra_expr, extra_reg, None);
}
let step_p5 = u16::try_from(agg.num_args).unwrap_or_default();
// bd-lgwjd(a): thread the aggregate's declared collation into the
// GROUP BY substrate AggStep (a direct `max(name)` under GROUP BY was
// emitted BINARY here, ignoring `agg.collation`). `agg_func_p4` yields
// a plain FuncName when no collation is present, so non-collated
// aggregates are unchanged.
b.emit_op(
Opcode::AggStep,
distinct_flag,
arg_base,
accum_reg,
agg_func_p4(&agg.name, agg.collation.as_ref()),
step_p5,
);
}
// Resolve FILTER skip label after AggStep.
if let Some(skip_lbl) = filter_skip_label {
b.resolve_label(skip_lbl);
}
}
// SorterNext: advance to next sorted row.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
sort_loop_body as i32,
0,
P4::None,
0,
);
// After loop: output final group (if any rows were processed).
// If first_flag is still > 0, table was empty — skip final output.
b.emit_jump_to_label(Opcode::IfPos, first_flag, 0, done_label, P4::None, 0);
// Finalize last group.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
for (i, agg) in agg_columns.iter().enumerate() {
let accum_reg = accum_base + i as i32;
b.emit_op(
Opcode::AggFinal,
accum_reg,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
}
// Build output row from prev_key (last group's keys) + accum + prev_nongrouped.
// Same as above: skip HAVING-only aggregate entries beyond out_col_count.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
{
let mut ng_idx = 0i32;
for (i, out_col) in output_cols.iter().take(out_col_count as usize).enumerate() {
match out_col {
GroupByOutputCol::GroupKey { sorter_col, .. } => {
b.emit_op(
Opcode::Copy,
prev_key_base + *sorter_col as i32,
out_regs + i as i32,
0,
P4::None,
0,
);
}
GroupByOutputCol::Aggregate { agg_index } => {
b.emit_op(
Opcode::Copy,
accum_base + *agg_index as i32,
out_regs + i as i32,
0,
P4::None,
0,
);
}
GroupByOutputCol::NonGroupedColumn { .. } => {
b.emit_op(
Opcode::Copy,
prev_nongrouped_base + ng_idx,
out_regs + i as i32,
0,
P4::None,
0,
);
ng_idx += 1;
}
}
}
}
// HAVING filter for the final group.
let final_skip = b.emit_label();
if let Some(having_expr) = having {
emit_having_filter(
b,
having_expr,
&output_cols,
&agg_columns,
&group_by_keys,
table,
out_regs,
final_skip,
);
}
// OFFSET: if offset counter > 0, skip this group's output.
if let Some(off_r) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, off_r, 1, final_skip, P4::None, 0);
}
b.emit_op(Opcode::ResultRow, out_regs, out_col_count, 0, P4::None, 0);
b.resolve_label(final_skip);
// Done: Close sorter + Halt.
b.resolve_label(done_label);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End target for Init jump.
b.resolve_label(end_label);
Ok(())
}
// ---------------------------------------------------------------------------
// INSERT codegen
// ---------------------------------------------------------------------------
/// Generate VDBE bytecode for an INSERT statement.
///
/// Pattern: `INSERT INTO t VALUES (?, ?, ...)`
///
/// Init → Transaction(write) → OpenWrite → Variable* → (IPK routing |
/// NewRowid) → MakeRecord → Insert → Close → Halt
#[allow(clippy::too_many_lines)]
pub fn codegen_insert(
b: &mut ProgramBuilder,
stmt: &InsertStatement,
schema: &[TableSchema],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let table = find_table(schema, &stmt.table.name)?;
if table.without_rowid {
return codegen_insert_without_rowid(b, stmt, table, schema, ctx);
}
let target_alias = stmt.alias.as_deref();
let table_cursor = 0_i32;
let end_label = b.emit_label();
// Init.
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (write, p2=1).
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
// OpenWrite for table.
b.emit_op(
Opcode::OpenWrite,
table_cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// OpenWrite for each index (bd-so1h: Phase 5I.3).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (idx_offset, index) in table.indexes.iter().enumerate() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(
Opcode::OpenWrite,
idx_cursor,
index.root_page,
0,
P4::Table(index.name.clone()),
0,
);
}
// Register table-to-index cursor metadata for REPLACE conflict resolution.
// This allows the engine's native_replace_row to clean up secondary index
// entries when a conflicting row is deleted.
register_table_index_meta(b, table, table_cursor);
// Conflict behavior. A statement-level `INSERT OR <algo>` (or the default
// ABORT) governs BOTH the pre-insert NOT NULL/CHECK checks on the candidate
// row and the no-conflict INSERT path's remaining (non-target) UNIQUE/PK
// constraints. The per-constraint `ON CONFLICT` chain is routed row-by-row
// in `codegen_insert_values`: each clause probes its own target in written
// order and the first whose target conflicts is applied (DO UPDATE rewrites
// the conflicting row; DO NOTHING skips the insert). A conflict on a
// constraint that no clause targets falls through to `stmt_level`
// (default ABORT), matching stock (multiple ON CONFLICT clauses, SQLite
// 3.35+). Stock enforces candidate NOT NULL/CHECK even for a row that will
// conflict, so these are NOT blanket-IGNORE'd (bd-aap9u).
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
let stmt_level: Option<ConflictAction> = stmt.or_conflict;
// Validate every clause's conflict target + DO UPDATE assignment/WHERE
// columns up front for error parity.
for clause in &stmt.upsert {
if let Some(target) = clause.target.as_ref()
&& find_upsert_target_index(table, Some(target)).is_none()
&& !upsert_target_matches_rowid_primary_key(table, target)
&& !upsert_target_matches_without_rowid_primary_key(table, target)
{
return Err(CodegenError::Unsupported(
"ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint".to_owned(),
));
}
if let UpsertAction::Update {
assignments,
where_clause,
} = &clause.action
{
for assign in assignments {
validate_assignment_target(table, &assign.target)?;
validate_upsert_expr_columns(&assign.value, table, target_alias)?;
}
if let Some(where_expr) = where_clause {
validate_upsert_expr_columns(where_expr, table, target_alias)?;
}
}
}
match &stmt.source {
InsertSource::Values(rows) => {
if rows.is_empty() {
return Err(CodegenError::Unsupported("empty VALUES".to_owned()));
}
let target_mapping = build_insert_target_mapping(&stmt.columns, table)?;
if let Some(mapping) = target_mapping.as_ref() {
codegen_insert_values(
b,
rows,
Some(mapping.expected_source_cols),
mapping.explicit_rowid_source_pos,
Some(&mapping.col_mapping),
table_cursor,
table,
schema,
&stmt.returning,
target_alias,
ctx,
oe_flag,
stmt_level,
&stmt.upsert,
)?;
} else {
codegen_insert_values(
b,
rows,
None,
None,
None,
table_cursor,
table,
schema,
&stmt.returning,
target_alias,
ctx,
oe_flag,
stmt_level,
&stmt.upsert,
)?;
}
}
InsertSource::Select(select_stmt) => {
let target_mapping = build_insert_target_mapping(&stmt.columns, table)?;
let expected_cols = if let Some(mapping) = target_mapping.as_ref() {
Some(mapping.expected_source_cols)
} else {
Some(table.columns.len())
};
codegen_insert_select(
b,
select_stmt,
table_cursor,
table,
schema,
&stmt.returning,
target_alias,
ctx,
oe_flag,
stmt_level,
expected_cols,
target_mapping
.as_ref()
.and_then(|mapping| mapping.explicit_rowid_source_pos),
target_mapping
.as_ref()
.map(|mapping| mapping.col_mapping.as_slice()),
)?;
}
InsertSource::DefaultValues => {
// Insert one row using column DEFAULT values (or NULL if none).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let n_cols = table.columns.len() as i32;
let concurrent_flag = i32::from(ctx.concurrent_mode);
let col_regs = b.alloc_regs(n_cols);
for (idx, col) in table.columns.iter().enumerate() {
#[allow(clippy::cast_possible_wrap)]
let reg = col_regs + idx as i32;
emit_default_value(b, col, reg)?;
}
let rowid_reg = b.alloc_reg();
if let Some(ipk_idx) = ctx.rowid_alias_col_idx {
// IPK column has a DEFAULT value — use it when non-NULL,
// otherwise auto-generate via NewRowid (matching VALUES path).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = col_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
table_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(done_label);
} else {
// No IPK column — always auto-generate.
b.emit_op(
Opcode::NewRowid,
table_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
}
// Evaluate STORED generated columns before packing the record.
emit_stored_generated_columns(b, table, col_regs);
let rec_reg = b.alloc_reg();
emit_strict_type_check(b, table, col_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies
// affinity, then evaluates constraints).
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_check_constraints(b, table, col_regs, None);
emit_not_null_constraints(b, table, col_regs, stmt_level, None);
let pk_oe = effective_oe(
stmt_level,
table
.columns
.iter()
.find(|c| c.is_ipk)
.and_then(|c| c.conflict_action),
);
// Apply column type affinities before packing the record.
let aff_str = table.affinity_string();
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols,
0,
P4::Affinity(aff_str.clone()),
0,
);
b.emit_op(
Opcode::MakeRecord,
col_regs,
n_cols,
rec_reg,
make_insert_record_p4(table, &aff_str),
0,
);
b.emit_op(
Opcode::Insert,
table_cursor,
rec_reg,
rowid_reg,
P4::Table(table.name.clone()),
pk_oe,
);
// Index maintenance: insert into each index (bd-so1h).
emit_index_inserts(b, table, table_cursor, col_regs, rowid_reg, stmt_level);
if !stmt.returning.is_empty() {
emit_returning(
b,
table_cursor,
table,
&stmt.returning,
target_alias,
rowid_reg,
)?;
}
}
}
// Close table cursor.
b.emit_op(Opcode::Close, table_cursor, 0, 0, P4::None, 0);
// Close index cursors (bd-so1h).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for idx_offset in 0..table.indexes.len() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End label.
b.resolve_label(end_label);
Ok(())
}
#[derive(Debug, Clone)]
struct InsertTargetMapping {
expected_source_cols: usize,
explicit_rowid_source_pos: Option<usize>,
col_mapping: Vec<Option<usize>>,
}
fn build_insert_target_mapping(
insert_columns: &[String],
table: &TableSchema,
) -> Result<Option<InsertTargetMapping>, CodegenError> {
if insert_columns.is_empty() {
return Ok(None);
}
enum RowidTarget {
Hidden(usize),
Ipk,
}
let mut rowid_target = None;
let mut col_mapping = vec![None; table.columns.len()];
for (source_pos, col_name) in insert_columns.iter().enumerate() {
if table.resolves_to_hidden_rowid(col_name) {
rowid_target = Some(RowidTarget::Hidden(source_pos));
continue;
}
let tbl_pos = table
.column_index(col_name)
.ok_or_else(|| CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_name.clone(),
})?;
if table.columns[tbl_pos].is_ipk {
rowid_target = Some(RowidTarget::Ipk);
col_mapping[tbl_pos] = Some(source_pos);
} else {
col_mapping[tbl_pos].get_or_insert(source_pos);
}
}
Ok(Some(InsertTargetMapping {
expected_source_cols: insert_columns.len(),
explicit_rowid_source_pos: match rowid_target {
Some(RowidTarget::Hidden(source_pos)) => Some(source_pos),
Some(RowidTarget::Ipk) | None => None,
},
col_mapping,
}))
}
/// Emit the check-before-insert conflict probe for one UPSERT `ON CONFLICT`
/// clause (rowid table).
///
/// `target` is the clause's conflict target (`None` for a bare `ON CONFLICT`).
/// On NO conflict, control jumps to `no_conflict_label` (the next clause in the
/// chain, or the normal-insert path for the last clause). On conflict, control
/// falls through with the table cursor positioned on the conflicting row; the
/// returned register holds that row's rowid.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
fn emit_upsert_probe(
b: &mut ProgramBuilder,
table: &TableSchema,
cursor: i32,
val_regs: i32,
rowid_reg: i32,
table_alias: Option<&str>,
target: Option<&UpsertTarget>,
no_conflict_label: Label,
) -> i32 {
if let Some((idx_offset, index)) = find_upsert_target_index(table, target) {
let attempted_row_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
emit_index_predicate_guard(b, index, &attempted_row_ctx, no_conflict_label);
// UNIQUE index conflict check.
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let idx_cursor = cursor + 1 + idx_offset as i32;
let n_key_cols = index.columns.len() as i32;
// Build probe key from attempted insert values.
let key_val_regs = b.alloc_regs(n_key_cols);
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (key_pos, col_name) in index.columns.iter().enumerate() {
if let Some(col_idx) = table.column_index(col_name) {
b.emit_op(
Opcode::Copy,
val_regs + col_idx as i32,
key_val_regs + key_pos as i32,
0,
P4::None,
0,
);
}
}
let key_rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
key_val_regs,
n_key_cols,
key_rec_reg,
P4::None,
0,
);
// NoConflict: jump to no_conflict_label if no match found.
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
key_rec_reg,
no_conflict_label,
P4::None,
0,
);
// Conflict: extract existing row's rowid from index.
let existing_rowid_reg = b.alloc_reg();
b.emit_op(Opcode::IdxRowid, idx_cursor, existing_rowid_reg, 0, P4::None, 0);
// Seek table cursor to the existing row.
b.emit_jump_to_label(
Opcode::NotExists,
cursor,
existing_rowid_reg,
no_conflict_label,
P4::None,
0,
);
existing_rowid_reg
} else if target.is_none()
&& table
.indexes
.iter()
.any(|index| index.is_unique && index.supports_direct_column_lookup())
{
// Omitted conflict target (SQLite 3.35+): DO UPDATE fires on whichever
// uniqueness constraint the new row violates first. Probe the rowid/IPK
// PRIMARY KEY, then every UNIQUE index in schema order; the first hit
// supplies the existing row and leaves the table cursor positioned on it.
let conflict_label = b.emit_label();
let found_rowid_reg = b.alloc_reg();
let pk_miss = b.emit_label();
b.emit_jump_to_label(Opcode::NotExists, cursor, rowid_reg, pk_miss, P4::None, 0);
b.emit_op(Opcode::Copy, rowid_reg, found_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, conflict_label, P4::None, 0);
b.resolve_label(pk_miss);
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (idx_offset, index) in table.indexes.iter().enumerate() {
if !index.is_unique || !index.supports_direct_column_lookup() {
continue;
}
let idx_miss = b.emit_label();
let idx_cursor = cursor + 1 + idx_offset as i32;
let n_key_cols = index.columns.len() as i32;
let key_val_regs = b.alloc_regs(n_key_cols);
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (key_pos, col_name) in index.columns.iter().enumerate() {
if let Some(col_idx) = table.column_index(col_name) {
b.emit_op(
Opcode::Copy,
val_regs + col_idx as i32,
key_val_regs + key_pos as i32,
0,
P4::None,
0,
);
}
}
let key_rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
key_val_regs,
n_key_cols,
key_rec_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
key_rec_reg,
idx_miss,
P4::None,
0,
);
b.emit_op(Opcode::IdxRowid, idx_cursor, found_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(
Opcode::NotExists,
cursor,
found_rowid_reg,
idx_miss,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, conflict_label, P4::None, 0);
b.resolve_label(idx_miss);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, no_conflict_label, P4::None, 0);
b.resolve_label(conflict_label);
found_rowid_reg
} else {
// PK conflict check (explicit PK/rowid target, or omitted target on a
// table with no UNIQUE indexes).
b.emit_jump_to_label(Opcode::NotExists, cursor, rowid_reg, no_conflict_label, P4::None, 0);
rowid_reg
}
}
/// Emit the DO UPDATE apply body for one UPSERT clause (rowid table): read the
/// conflicting row, evaluate the optional `WHERE`, apply the SET assignments
/// (with `excluded.*` bound to the attempted-insert registers), re-validate
/// constraints, delete the old row, and re-insert the rewritten image. The
/// table cursor must already be positioned on the conflicting row (rowid in
/// `update_rowid_reg`, from [`emit_upsert_probe`]). Does not emit the trailing
/// jump to the done label — the caller does.
#[allow(clippy::too_many_arguments)]
fn emit_upsert_do_update_apply(
b: &mut ProgramBuilder,
table: &TableSchema,
cursor: i32,
schema: &[TableSchema],
val_regs: i32,
rowid_reg: i32,
update_rowid_reg: i32,
assignments: &[fsqlite_ast::Assignment],
where_clause: Option<&Expr>,
n_cols: usize,
n_cols_i32: i32,
aff_str: &str,
stmt_level: Option<ConflictAction>,
returning: &[ResultColumn],
table_alias: Option<&str>,
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
// Allocate registers for existing row columns and read them from the cursor.
let existing_regs = b.alloc_regs(n_cols_i32);
for col_idx in 0..n_cols {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let col_i = col_idx as i32;
if table.columns.get(col_idx).is_some_and(|c| c.is_ipk) {
b.emit_op(Opcode::Rowid, cursor, existing_regs + col_i, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Column, cursor, col_i, existing_regs + col_i, P4::None, 0);
}
}
// Build ScanCtx for evaluating DO UPDATE expressions. "excluded" maps to
// val_regs (the attempted insert values); unqualified column refs resolve
// from existing_regs (the current row). bd-xjfrt: both ctxs carry the real
// `schema` (so subqueries resolve, not the "no schema → NULL" arm) and
// existing_ctx exposes `excluded.*` as a register-backed secondary so
// `... WHERE s.k = excluded.k` correlates against val_regs.
let excluded_secondary = [SecondaryScan {
cursor,
table,
table_alias: Some("excluded"),
register_base: Some(val_regs),
}];
let excluded_ctx = ScanCtx {
cursor,
table,
table_alias: Some("excluded"),
schema: Some(schema),
register_base: Some(val_regs),
secondaries: &[],
};
let existing_ctx = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: Some(existing_regs),
secondaries: &excluded_secondary,
};
let existing_hidden_rowid_reg = ctx
.rowid_alias_col_idx
.map(|ipk_idx| existing_regs + ipk_idx as i32);
let excluded_hidden_rowid_reg = rowid_reg;
// Optional WHERE clause on the DO UPDATE action.
let skip_update_label = if let Some(where_expr) = where_clause {
let label = b.emit_label();
let where_reg = b.alloc_reg();
emit_upsert_expr(
b,
where_expr,
where_reg,
&existing_ctx,
&excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
// If WHERE is false/NULL, skip the update (jump to done).
b.emit_jump_to_label(Opcode::IfNot, where_reg, 1, label, P4::None, 0);
Some(label)
} else {
None
};
emit_upsert_assignments(
b,
assignments,
table,
existing_regs,
&existing_ctx,
&excluded_ctx,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
)?;
// Validate the rewritten image before removing the old row.
emit_strict_type_check(b, table, existing_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value.
b.emit_op(
Opcode::Affinity,
existing_regs,
table.columns.len() as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_check_constraints(b, table, existing_regs, None);
emit_not_null_constraints(b, table, existing_regs, stmt_level, None);
emit_index_deletes(b, table, cursor);
b.emit_op(Opcode::Delete, cursor, 0, 0, P4::None, OPFLAG_ISUPDATE);
// An UPSERT assignment may rewrite the INTEGER PRIMARY KEY. Reinsert at the
// new rowid, not the conflict victim's old id.
let mut final_rowid_reg = update_rowid_reg;
if let Some(ipk_idx) = ctx
.rowid_alias_col_idx
.or_else(|| table.columns.iter().position(|column| column.is_ipk))
{
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = existing_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let rowid_done_label = b.emit_label();
final_rowid_reg = b.alloc_reg();
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, ipk_reg, final_rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, rowid_done_label, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
cursor,
final_rowid_reg,
i32::from(ctx.concurrent_mode),
P4::None,
0,
);
b.emit_op(Opcode::Copy, final_rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(rowid_done_label);
}
let update_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
existing_regs,
n_cols_i32,
update_rec,
make_insert_record_p4(table, aff_str),
0,
);
// bd-sque1: the DO UPDATE apply is semantically an UPDATE, so its re-insert
// must ABORT — not REPLACE — on a uniqueness conflict. The conflict victim
// was already deleted above, so a same-key upsert never collides; ABORT
// fires only when the assignment rewrites the rowid/PK/UNIQUE key onto a
// DIFFERENT existing row, matching stock.
b.emit_op(
Opcode::Insert,
cursor,
update_rec,
final_rowid_reg,
P4::Table(table.name.clone()),
OE_ABORT | OPFLAG_ISUPDATE,
);
emit_index_inserts(
b,
table,
cursor,
existing_regs,
final_rowid_reg,
Some(ConflictAction::Abort),
);
if !returning.is_empty() {
emit_returning(b, cursor, table, returning, table_alias, final_rowid_reg)?;
}
if let Some(label) = skip_update_label {
b.resolve_label(label);
}
Ok(())
}
/// Emit the INSERT loop for `VALUES (row), (row), ...`.
///
/// # Arguments
/// * `oe_flag` - Conflict resolution flag (OE_ABORT, OE_IGNORE, OE_REPLACE, etc.)
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
fn codegen_insert_values(
b: &mut ProgramBuilder,
rows: &[Vec<Expr>],
expected_source_cols: Option<usize>,
explicit_rowid_source_pos: Option<usize>,
col_mapping: Option<&[Option<usize>]>,
cursor: i32,
table: &TableSchema,
schema: &[TableSchema],
returning: &[ResultColumn],
table_alias: Option<&str>,
ctx: &CodegenContext,
oe_flag: u16,
stmt_level: Option<ConflictAction>,
upserts: &[UpsertClause],
) -> Result<(), CodegenError> {
// Conflict action for the table (rowid/INTEGER PRIMARY KEY) row: a
// statement-level `INSERT OR <algo>` overrides the IPK column's declared
// `PRIMARY KEY ON CONFLICT <algo>`.
let ipk_conflict = table
.columns
.iter()
.find(|c| c.is_ipk)
.and_then(|c| c.conflict_action);
let pk_oe = effective_oe(stmt_level, ipk_conflict);
let n_source_cols = rows
.first()
.ok_or_else(|| CodegenError::Unsupported("empty VALUES".to_owned()))?
.len();
let expected_source_cols = expected_source_cols.unwrap_or(table.columns.len());
if n_source_cols != expected_source_cols {
let message = if expected_source_cols == table.columns.len() && col_mapping.is_none() {
format!(
"table {} has {} columns but {} values were supplied",
table.name,
table.columns.len(),
n_source_cols
)
} else {
// An explicit target column list uses stock's shorter form.
format!("{n_source_cols} values for {expected_source_cols} columns")
};
return Err(CodegenError::SqlError(message));
}
let rowid_reg = b.alloc_reg();
let source_regs = b.alloc_regs(n_source_cols as i32);
let mapped_regs = col_mapping.map(|_| b.alloc_regs(table.columns.len() as i32));
let rec_reg = b.alloc_reg();
let concurrent_flag = i32::from(ctx.concurrent_mode);
for (row_index, row_values) in rows.iter().enumerate() {
if row_values.len() != n_source_cols {
return Err(CodegenError::SqlError(
"all VALUES must have the same number of terms".to_owned(),
));
}
// Emit value expressions into registers. A bare column reference in a
// VALUES row is never resolvable — a VALUES row has no source row — so
// stock SQLite errors "no such column: X" rather than resolving it to
// NULL (bd-xxqg5). DQS-ON double-quoted values are already rewritten to
// string literals before codegen (bd-82jdw dqs_proactive_rewrite), so
// only an unquoted bare column can reach here.
for (i, val_expr) in row_values.iter().enumerate() {
if let Expr::Column(col_ref, _) = val_expr
&& col_ref.table.is_none()
{
return Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_ref.column.to_string(),
});
}
let reg = source_regs + i as i32;
emit_expr(b, val_expr, reg, None);
}
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let (val_regs, n_cols) = if let Some(mapping) = col_mapping {
let table_regs = mapped_regs.expect("mapped registers allocated");
for (tbl_idx, src) in mapping.iter().enumerate() {
let dest = table_regs + tbl_idx as i32;
if let Some(source_pos) = src {
b.emit_op(
Opcode::Copy,
source_regs + *source_pos as i32,
dest,
0,
P4::None,
0,
);
} else {
emit_default_value(b, &table.columns[tbl_idx], dest)?;
}
}
(table_regs, table.columns.len())
} else {
(source_regs, n_source_cols)
};
// Rowid determination precedence:
// 1. explicit rowid/_rowid_/oid in INSERT column list
// 2. INTEGER PRIMARY KEY column value
// 3. auto-generated rowid
if let Some(source_pos) = explicit_rowid_source_pos {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let rowid_value_reg = source_regs + source_pos as i32;
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = ctx
.rowid_alias_col_idx
.map(|ipk_idx| val_regs + ipk_idx as i32);
let auto_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, rowid_value_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, rowid_value_reg, rowid_reg, 0, P4::None, 0);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.resolve_label(done_label);
} else if let Some(ipk_idx) = ctx.rowid_alias_col_idx {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = val_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let done_label = b.emit_label();
// If the user-supplied IPK value is NULL, jump to auto-generate.
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
// Non-NULL path: copy user value into rowid register.
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
// NULL path: auto-generate rowid, then sync it back into the
// IPK column register so MakeRecord includes the real rowid.
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(done_label);
} else {
// No IPK column — always auto-generate.
b.emit_op(
Opcode::NewRowid,
cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
}
// Evaluate STORED generated columns before packing the record.
emit_stored_generated_columns(b, table, val_regs);
// STRICT type check BEFORE affinity (SQLite validates raw storage
// classes, then applies affinity for the on-disk format).
emit_strict_type_check(b, table, val_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity,
// then evaluates constraints).
b.emit_op(
Opcode::Affinity,
val_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
// CHECK / NOT NULL validation. A row is skipped (IGNORE) when the
// statement is `INSERT OR IGNORE`, or when a violated NOT NULL column
// declares its own `ON CONFLICT IGNORE`. We allocate the shared skip
// label whenever any of those is possible.
let nn_ignore = table.columns.iter().any(|c| {
c.notnull && !c.is_ipk && effective_oe(stmt_level, c.conflict_action) == OE_IGNORE
});
let ignore_skip = if oe_flag == OE_IGNORE || nn_ignore {
Some(b.emit_label())
} else {
None
};
// CHECK constraints have no per-column conflict clause here, so they
// only skip under a statement-level IGNORE.
let check_ignore = if oe_flag == OE_IGNORE {
ignore_skip
} else {
None
};
emit_check_constraints(b, table, val_regs, check_ignore);
emit_not_null_constraints(b, table, val_regs, stmt_level, ignore_skip);
// Apply column type affinities before packing the record.
let aff_str = table.affinity_string();
b.emit_op(
Opcode::Affinity,
val_regs,
n_cols as i32,
0,
P4::Affinity(aff_str.clone()),
0,
);
// MakeRecord: pack columns into a record.
let n_cols_i32 = n_cols as i32;
// bd-bld9w.7 family (a): the compile-time preformatted record bakes TEXT as
// UTF-8, so only use it for a UTF-8 database; for UTF-16 fall through to the
// encoding-aware runtime MakeRecord path.
let preformatted_record = if matches!(ctx.text_encoding, TextEncoding::Utf8) {
try_build_preformatted_insert_record(row_values, table, col_mapping)
} else {
None
};
if preformatted_record.is_some() {
tracing::debug!(
target: "fsqlite_vdbe::insert_preformat",
table = %table.name,
row_index,
explicit_column_mapping = col_mapping.is_some(),
column_count = n_cols,
"preformatted INSERT record at codegen"
);
}
// UPSERT: a chain of ON CONFLICT clauses (SQLite 3.35+). Each clause
// probes its own conflict target in written order; the first clause
// whose target is violated is applied (DO UPDATE rewrites the specific
// conflicting row; DO NOTHING skips the insert). A conflict on a
// constraint that no clause targets falls through to the normal insert,
// where `stmt_level` (default ABORT) raises it — matching stock.
if !upserts.is_empty() {
let done_label = b.emit_label();
let insert_label = b.emit_label();
for (clause_idx, clause) in upserts.iter().enumerate() {
// NO conflict on this clause's target routes to the next clause;
// the last clause routes to the normal-insert path.
let no_conflict_label = if clause_idx + 1 == upserts.len() {
insert_label
} else {
b.emit_label()
};
let existing_rowid_reg = emit_upsert_probe(
b,
table,
cursor,
val_regs,
rowid_reg,
table_alias,
clause.target.as_ref(),
no_conflict_label,
);
match &clause.action {
UpsertAction::Nothing => {
// Conflict on this target -> skip the insert entirely.
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
}
UpsertAction::Update {
assignments,
where_clause,
} => {
emit_upsert_do_update_apply(
b,
table,
cursor,
schema,
val_regs,
rowid_reg,
existing_rowid_reg,
assignments,
where_clause.as_deref(),
n_cols,
n_cols_i32,
&aff_str,
stmt_level,
returning,
table_alias,
ctx,
)?;
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
}
}
if clause_idx + 1 != upserts.len() {
b.resolve_label(no_conflict_label);
}
}
// --- No clause matched: normal insert. Non-target UNIQUE/PK
// constraints are enforced by emit_index_inserts under stmt_level
// (default ABORT), so a conflict on a constraint no clause targets
// raises the error, matching stock. ---
b.resolve_label(insert_label);
let insert_p4 = emit_table_insert_record(
b,
val_regs,
n_cols_i32,
rec_reg,
table,
&aff_str,
preformatted_record.as_deref(),
);
b.emit_op(Opcode::Insert, cursor, rec_reg, rowid_reg, insert_p4, pk_oe);
emit_index_inserts(b, table, cursor, val_regs, rowid_reg, stmt_level);
if !returning.is_empty() {
emit_returning(b, cursor, table, returning, table_alias, rowid_reg)?;
}
b.resolve_label(done_label);
} else {
// No upsert — normal insert path.
// GH #158: INSERT OR IGNORE on a rowid / INTEGER-PRIMARY-KEY conflict
// must skip the WHOLE row, RETURNING included. Opcode::Insert with
// OE_IGNORE suppresses the write on conflict, but execution falls
// through to emit_returning, which re-seeks the rowid and would emit
// the PRE-EXISTING row. Pre-probe the rowid and jump to the
// ignore-skip label on conflict, mirroring the WITHOUT ROWID path.
if pk_oe == OE_IGNORE
&& let Some(skip) = ignore_skip
{
let do_insert = b.emit_label();
// NotExists jumps to do_insert when the rowid is free (no
// conflict); on an existing rowid we fall through and skip.
b.emit_jump_to_label(Opcode::NotExists, cursor, rowid_reg, do_insert, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, skip, P4::None, 0);
b.resolve_label(do_insert);
}
let insert_p4 = emit_table_insert_record(
b,
val_regs,
n_cols_i32,
rec_reg,
table,
&aff_str,
preformatted_record.as_deref(),
);
b.emit_op(Opcode::Insert, cursor, rec_reg, rowid_reg, insert_p4, pk_oe);
emit_index_inserts(b, table, cursor, val_regs, rowid_reg, stmt_level);
if !returning.is_empty() {
emit_returning(b, cursor, table, returning, table_alias, rowid_reg)?;
}
}
// Resolve OR IGNORE skip label after all insert logic for this row.
if let Some(skip) = ignore_skip {
b.resolve_label(skip);
}
}
Ok(())
}
fn emit_table_insert_record(
b: &mut ProgramBuilder,
source_regs: i32,
column_count: i32,
target_reg: i32,
table: &TableSchema,
affinity: &str,
preformatted_record: Option<&[u8]>,
) -> P4 {
if let Some(record) = preformatted_record {
// Keep the preformatted record off the register file entirely.
// INSERT can consume the baked row image directly from P4.
P4::Blob(record.to_vec())
} else {
b.emit_op(
Opcode::MakeRecord,
source_regs,
column_count,
target_reg,
make_insert_record_p4(table, affinity),
0,
);
P4::Table(table.name.clone())
}
}
fn make_insert_record_p4(table: &TableSchema, affinity: &str) -> P4 {
try_build_precomputed_record_header(table)
.map(P4::PrecomputedHeader)
.unwrap_or_else(|| P4::Affinity(affinity.to_owned()))
}
fn try_build_precomputed_record_header(table: &TableSchema) -> Option<PrecomputedRecordHeader> {
let mut kinds = Vec::with_capacity(table.columns.len());
for column in &table.columns {
kinds.push(precomputed_serial_type_kind(column)?);
}
Some(PrecomputedRecordHeader::new(&kinds))
}
fn precomputed_serial_type_kind(column: &ColumnInfo) -> Option<PrecomputedSerialTypeKind> {
if column.is_ipk {
return Some(PrecomputedSerialTypeKind::NullPlaceholder);
}
match column.strict_type {
Some(StrictColumnType::Integer) => Some(PrecomputedSerialTypeKind::IntegerOrNull),
Some(StrictColumnType::Real) => Some(PrecomputedSerialTypeKind::RealOrNull),
_ => None,
}
}
/// Build a table-record blob at codegen time for rows whose stored image is
/// fully determined before execution.
///
/// This intentionally stays narrow:
/// - only literal `VALUES` expressions are admitted
/// - omitted/default/generated columns fall back to runtime `MakeRecord`
/// - rowid/IPK storage still uses the runtime key path; the record stores NULL
/// placeholders for INTEGER PRIMARY KEY aliases just like `MakeRecord`
/// - CURRENT_* literals stay on the runtime path so the value registers and
/// record blob cannot drift from separate timestamp materializations
fn try_build_preformatted_insert_record(
row_values: &[Expr],
table: &TableSchema,
col_mapping: Option<&[Option<usize>]>,
) -> Option<Vec<u8>> {
if table.columns.iter().any(|col| col.generated_expr.is_some()) {
return None;
}
let mut stored_values = Vec::with_capacity(table.columns.len());
for (table_idx, column) in table.columns.iter().enumerate() {
if column.is_ipk {
stored_values.push(SqliteValue::Null);
continue;
}
let expr = match col_mapping {
Some(mapping) => {
let source_idx = mapping.get(table_idx)?.as_ref()?;
row_values.get(*source_idx)?
}
None => row_values.get(table_idx)?,
};
let value = compile_time_insert_value(expr)?;
let value = if let Some(strict_type) = column.strict_type {
value.validate_strict(strict_type).ok()?
} else {
value
};
stored_values.push(value.apply_affinity(type_affinity_for_char(column.affinity)));
}
Some(fsqlite_types::record::serialize_record(&stored_values))
}
fn compile_time_insert_value(expr: &Expr) -> Option<SqliteValue> {
let Expr::Literal(literal, _) = expr else {
return None;
};
Some(match literal {
Literal::Integer(value) => SqliteValue::Integer(*value),
Literal::Float(value) => SqliteValue::Float(*value),
Literal::String(value) => SqliteValue::Text(SmallText::new(value.as_str())),
Literal::Blob(value) => SqliteValue::Blob(Arc::from(value.as_slice())),
Literal::Null => SqliteValue::Null,
Literal::True => SqliteValue::Integer(1),
Literal::False => SqliteValue::Integer(0),
Literal::CurrentTimestamp | Literal::CurrentDate | Literal::CurrentTime => return None,
})
}
fn type_affinity_for_char(ch: char) -> TypeAffinity {
match ch {
'B' | 'b' => TypeAffinity::Text,
'C' | 'c' => TypeAffinity::Numeric,
'D' | 'd' => TypeAffinity::Integer,
'E' | 'e' => TypeAffinity::Real,
_ => TypeAffinity::Blob,
}
}
/// Emit the INSERT loop for `INSERT INTO target SELECT ... FROM source`.
///
/// Opens the source table for reading (cursor = `write_cursor + 1`), scans
/// rows with an optional WHERE filter, reads projected columns, and inserts
/// each row into the target table.
///
/// # Arguments
/// * `oe_flag` - Conflict resolution flag (OE_ABORT, OE_IGNORE, OE_REPLACE, etc.)
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn codegen_insert_select(
b: &mut ProgramBuilder,
select_stmt: &SelectStatement,
write_cursor: i32,
target_table: &TableSchema,
schema: &[TableSchema],
returning: &[ResultColumn],
target_alias: Option<&str>,
ctx: &CodegenContext,
oe_flag: u16,
stmt_level: Option<ConflictAction>,
expected_cols: Option<usize>,
explicit_rowid_source_pos: Option<usize>,
col_mapping: Option<&[Option<usize>]>,
) -> Result<(), CodegenError> {
// Extract columns, FROM, and WHERE from the inner SELECT.
let (columns, from, where_clause) = match &select_stmt.body.select {
SelectCore::Select {
columns,
from,
where_clause,
..
} => (columns, from, where_clause),
SelectCore::Values(_) => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with VALUES body".to_owned(),
));
}
};
if from.is_none() {
return codegen_insert_select_without_from(
b,
columns,
where_clause.as_deref(),
write_cursor,
target_table,
returning,
target_alias,
ctx,
oe_flag,
stmt_level,
expected_cols,
explicit_rowid_source_pos,
col_mapping,
);
}
// SAFETY: `from.is_none()` is handled above; `.expect` cannot panic.
let from_clause = from.as_ref().expect("from already checked above");
let (src_table_name, src_table_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.as_deref()),
_ => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT from non-table source".to_owned(),
));
}
};
let src_table = find_table(schema, src_table_name)?;
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let read_cursor = write_cursor + 1 + target_table.indexes.len() as i32;
// Determine the number of output columns from the SELECT.
let n_cols = result_column_count(columns, src_table);
let n_cols_usize = usize::try_from(n_cols).unwrap_or(0);
if let Some(expected) = expected_cols
&& n_cols_usize != expected
{
return Err(CodegenError::SqlError(format!(
"table {} has {} columns but {} values were supplied",
target_table.name, expected, n_cols_usize
)));
}
// Allocate registers for the scan → insert pipeline.
let rowid_reg = b.alloc_reg();
let val_regs = b.alloc_regs(n_cols);
let rec_reg = b.alloc_reg();
let concurrent_flag = i32::from(ctx.concurrent_mode);
let done_label = b.emit_label();
// OpenRead on source table.
b.emit_op(
Opcode::OpenRead,
read_cursor,
src_table.root_page,
0,
P4::Table(src_table.name.clone()),
0,
);
// Rewind to first row; jump to done if source is empty.
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, read_cursor, 0, done_label, P4::None, 0);
// WHERE filter on source rows (skip non-matching).
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
read_cursor,
src_table,
src_table_alias,
schema,
skip_label,
);
}
// Read projected columns from source into val_regs.
emit_column_reads(
b,
read_cursor,
columns,
src_table,
src_table_alias,
schema,
val_regs,
)?;
// When an explicit column list is provided, reorder from SELECT output
// order to table-schema order, filling unmentioned columns with defaults.
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let (final_regs, final_n_cols) = if let Some(mapping) = col_mapping {
let n_table_cols = target_table.columns.len() as i32;
let table_regs = b.alloc_regs(n_table_cols);
for (tbl_idx, src) in mapping.iter().enumerate() {
let dest = table_regs + tbl_idx as i32;
if let Some(sel_pos) = src {
b.emit_op(
Opcode::Copy,
val_regs + *sel_pos as i32,
dest,
0,
P4::None,
0,
);
} else {
emit_default_value(b, &target_table.columns[tbl_idx], dest)?;
}
}
(table_regs, n_table_cols)
} else {
(val_regs, n_cols)
};
// Rowid determination: explicit hidden rowid takes precedence over IPK.
if let Some(source_pos) = explicit_rowid_source_pos {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let rowid_value_reg = val_regs + source_pos as i32;
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = ctx
.rowid_alias_col_idx
.map(|ipk_idx| final_regs + ipk_idx as i32);
let auto_label = b.emit_label();
let done_rowid = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, rowid_value_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, rowid_value_reg, rowid_reg, 0, P4::None, 0);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_rowid, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.resolve_label(done_rowid);
} else if let Some(ipk_idx) = ctx.rowid_alias_col_idx {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let ipk_reg = final_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let done_rowid = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
// Non-NULL: use the selected value as rowid.
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_rowid, P4::None, 0);
// NULL: auto-generate and sync back.
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(done_rowid);
} else {
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
}
// Evaluate STORED generated columns before packing the record.
emit_stored_generated_columns(b, target_table, final_regs);
// Apply column type affinities before packing the record.
// STRICT type check before affinity.
emit_strict_type_check(b, target_table, final_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
final_regs,
final_n_cols,
0,
P4::Affinity(target_table.affinity_string()),
0,
);
let nn_ignore = target_table.columns.iter().any(|c| {
c.notnull && !c.is_ipk && effective_oe(stmt_level, c.conflict_action) == OE_IGNORE
});
let ignore_target = if oe_flag == OE_IGNORE || nn_ignore {
Some(skip_label)
} else {
None
};
let check_ignore = if oe_flag == OE_IGNORE {
ignore_target
} else {
None
};
emit_check_constraints(b, target_table, final_regs, check_ignore);
emit_not_null_constraints(b, target_table, final_regs, stmt_level, ignore_target);
let pk_oe = effective_oe(
stmt_level,
target_table
.columns
.iter()
.find(|c| c.is_ipk)
.and_then(|c| c.conflict_action),
);
let aff_str = target_table.affinity_string();
b.emit_op(
Opcode::Affinity,
final_regs,
final_n_cols,
0,
P4::Affinity(aff_str.clone()),
0,
);
// MakeRecord from the read column values.
b.emit_op(
Opcode::MakeRecord,
final_regs,
final_n_cols,
rec_reg,
make_insert_record_p4(target_table, &aff_str),
0,
);
// Insert into target table.
b.emit_op(
Opcode::Insert,
write_cursor,
rec_reg,
rowid_reg,
P4::Table(target_table.name.clone()),
pk_oe,
);
// Index maintenance: insert into each index (bd-so1h).
emit_index_inserts(
b,
target_table,
write_cursor,
final_regs,
rowid_reg,
stmt_level,
);
// RETURNING clause: position cursor on inserted row and read columns.
if !returning.is_empty() {
emit_returning(
b,
write_cursor,
target_table,
returning,
target_alias,
rowid_reg,
)?;
}
// Skip label for WHERE-filtered rows.
b.resolve_label(skip_label);
// Next: advance to next source row.
let loop_body = (loop_start + 1) as i32;
b.emit_op(Opcode::Next, read_cursor, loop_body, 0, P4::None, 0);
// Done: close source cursor.
b.resolve_label(done_label);
b.emit_op(Opcode::Close, read_cursor, 0, 0, P4::None, 0);
Ok(())
}
#[allow(
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn codegen_insert_select_without_from(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
write_cursor: i32,
target_table: &TableSchema,
returning: &[ResultColumn],
target_alias: Option<&str>,
ctx: &CodegenContext,
oe_flag: u16,
stmt_level: Option<ConflictAction>,
expected_cols: Option<usize>,
explicit_rowid_source_pos: Option<usize>,
col_mapping: Option<&[Option<usize>]>,
) -> Result<(), CodegenError> {
let n_cols = result_column_count_without_from(columns)?;
let n_cols_usize = usize::try_from(n_cols).unwrap_or(0);
if let Some(expected) = expected_cols
&& n_cols_usize != expected
{
return Err(CodegenError::SqlError(format!(
"table {} has {} columns but {} values were supplied",
target_table.name, expected, n_cols_usize
)));
}
let rowid_reg = b.alloc_reg();
let val_regs = b.alloc_regs(n_cols);
let rec_reg = b.alloc_reg();
let concurrent_flag = i32::from(ctx.concurrent_mode);
let done_label = b.emit_label();
if let Some(where_expr) = where_clause {
let filter_reg = b.alloc_temp();
emit_expr(b, where_expr, filter_reg, None);
// Treat NULL WHERE results as false (skip insert).
b.emit_jump_to_label(Opcode::IfNot, filter_reg, 1, done_label, P4::None, 0);
b.free_temp(filter_reg);
}
emit_projection_without_from(b, columns, val_regs)?;
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let (final_regs, final_n_cols) = if let Some(mapping) = col_mapping {
let n_table_cols = target_table.columns.len() as i32;
let table_regs = b.alloc_regs(n_table_cols);
for (tbl_idx, src) in mapping.iter().enumerate() {
let dest = table_regs + tbl_idx as i32;
if let Some(sel_pos) = src {
b.emit_op(
Opcode::Copy,
val_regs + *sel_pos as i32,
dest,
0,
P4::None,
0,
);
} else {
emit_default_value(b, &target_table.columns[tbl_idx], dest)?;
}
}
(table_regs, n_table_cols)
} else {
(val_regs, n_cols)
};
// Rowid determination: explicit hidden rowid takes precedence over IPK.
if let Some(source_pos) = explicit_rowid_source_pos {
let rowid_value_reg = val_regs + source_pos as i32;
let ipk_reg = ctx
.rowid_alias_col_idx
.map(|ipk_idx| final_regs + ipk_idx as i32);
let auto_label = b.emit_label();
let done_rowid = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, rowid_value_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, rowid_value_reg, rowid_reg, 0, P4::None, 0);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_rowid, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
if let Some(ipk_reg) = ipk_reg {
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
}
b.resolve_label(done_rowid);
} else if let Some(ipk_idx) = ctx.rowid_alias_col_idx {
let ipk_reg = final_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let done_rowid = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
// Non-NULL: use the selected value as rowid.
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_rowid, P4::None, 0);
// NULL: auto-generate and sync back.
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(done_rowid);
} else {
b.emit_op(
Opcode::NewRowid,
write_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
}
// Evaluate STORED generated columns before packing the record.
emit_stored_generated_columns(b, target_table, final_regs);
let nn_ignore = target_table.columns.iter().any(|c| {
c.notnull && !c.is_ipk && effective_oe(stmt_level, c.conflict_action) == OE_IGNORE
});
let ignore_target = if oe_flag == OE_IGNORE || nn_ignore {
Some(done_label)
} else {
None
};
let check_ignore = if oe_flag == OE_IGNORE {
ignore_target
} else {
None
};
let pk_oe = effective_oe(
stmt_level,
target_table
.columns
.iter()
.find(|c| c.is_ipk)
.and_then(|c| c.conflict_action),
);
// STRICT type check before affinity.
emit_strict_type_check(b, target_table, final_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
final_regs,
final_n_cols,
0,
P4::Affinity(target_table.affinity_string()),
0,
);
emit_check_constraints(b, target_table, final_regs, check_ignore);
emit_not_null_constraints(b, target_table, final_regs, stmt_level, ignore_target);
// Apply column type affinities before packing the record.
let aff_str = target_table.affinity_string();
b.emit_op(
Opcode::Affinity,
final_regs,
final_n_cols,
0,
P4::Affinity(aff_str.clone()),
0,
);
b.emit_op(
Opcode::MakeRecord,
final_regs,
final_n_cols,
rec_reg,
make_insert_record_p4(target_table, &aff_str),
0,
);
b.emit_op(
Opcode::Insert,
write_cursor,
rec_reg,
rowid_reg,
P4::Table(target_table.name.clone()),
pk_oe,
);
emit_index_inserts(
b,
target_table,
write_cursor,
final_regs,
rowid_reg,
stmt_level,
);
if !returning.is_empty() {
emit_returning(
b,
write_cursor,
target_table,
returning,
target_alias,
rowid_reg,
)?;
}
b.resolve_label(done_label);
Ok(())
}
// ---------------------------------------------------------------------------
// UPDATE codegen
// ---------------------------------------------------------------------------
/// Generate VDBE bytecode for an UPDATE statement.
///
/// Pattern: `UPDATE t SET col = ? WHERE rowid = ?`
///
/// Reads ALL existing columns, replaces changed ones, writes back complete
/// record (no partial patches — this is normative per §10.6).
#[allow(clippy::too_many_lines)]
pub fn codegen_update(
b: &mut ProgramBuilder,
stmt: &UpdateStatement,
schema: &[TableSchema],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let table_name = table_name_from_qualified(&stmt.table);
let table = find_table(schema, table_name)?;
if table.without_rowid {
return codegen_update_without_rowid(b, stmt, table, schema, ctx);
}
let cursor = 0_i32;
let n_cols = table.columns.len();
let end_label = b.emit_label();
let done_label = b.emit_label();
if let Some(from_clause) = &stmt.from {
return codegen_update_from(b, stmt, from_clause, schema, ctx);
}
if !stmt.order_by.is_empty() || stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"UPDATE ORDER BY/LIMIT/OFFSET must be materialized before codegen".to_owned(),
));
}
for assign in &stmt.assignments {
validate_single_table_expr_columns(&assign.value, table, stmt.table.alias.as_deref())?;
}
if let Some(where_expr) = &stmt.where_clause {
validate_single_table_expr_columns(where_expr, table, stmt.table.alias.as_deref())?;
}
validate_single_table_result_columns(
&stmt.returning,
table,
stmt.table.alias.as_deref(),
stmt.table.name.schema.as_deref(),
)?;
// Init.
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (write).
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
// Resolve assignment targets to column indices.
let assignment_cols = collect_update_assignment_columns(table, &stmt.assignments)?;
let update_index_mask = update_index_maintenance_mask(table, &assignment_cols);
// OpenWrite for table.
let table_cursor = cursor;
b.emit_op(
Opcode::OpenWrite,
table_cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// OpenWrite for each index (bd-2f9t: Phase 5I.5).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (idx_offset, index) in table.indexes.iter().enumerate() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(
Opcode::OpenWrite,
idx_cursor,
index.root_page,
0,
P4::Table(index.name.clone()),
0,
);
}
// Register table-to-index cursor metadata for REPLACE conflict resolution.
register_table_index_meta(b, table, table_cursor);
let rowid_target = extract_rowid_target_expr(stmt.where_clause.as_ref(), Some(table), None);
// bd-update-rowid-in / bd-update-rowid-in-residual: `UPDATE ... WHERE <rowid> IN (<int literals>)
// [AND <residual>]` collects the listed rows with one `SeekRowid` each (Pass 1) instead of
// full-scanning; a residual is re-applied per seeked row. After `rowid = const`.
let rowid_in_list = if rowid_target.is_none() {
extract_rowid_in_list_residual_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
// bd-update-rowid-range: `UPDATE ... WHERE <rowid> <range>` collects the [lower, upper] slice with a
// bounded seek+walk instead of full-scanning. Bare range, integer-literal bounds only (so Pass 1 emits
// no anon placeholders — the SET placeholders are numbered in Pass 2 as before).
let rowid_range = if rowid_target.is_none() && rowid_in_list.is_none() {
extract_rowid_range_target(
stmt.where_clause.as_ref(),
Some(table),
stmt.table.alias.as_deref(),
)
.filter(|range| {
range.lower.is_some()
&& rowid_range_fast_path_is_safe(*range)
&& rowid_range_bounds_are_int_literals(range)
})
} else {
None
};
// bd-update-rowid-eq-residual: `UPDATE ... WHERE <rowid> = <const> AND <residual>` seeks the single
// target row and applies the residual — the common optimistic-lock shape (`WHERE id = ? AND version =
// ?`), routed through the two-pass collect so the RowSet path (and its Halloween safety) is reused.
let rowid_eq_residual =
if rowid_target.is_none() && rowid_in_list.is_none() && rowid_range.is_none() {
extract_rowid_eq_residual_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
// bd-update-index-eq(-residual): `UPDATE ... WHERE <single-col-int-indexed> = <int> [AND <residual>]`
// seeks the index for the candidate rowids (fresh read cursor), applies the residual per candidate,
// instead of full-scanning. After all rowid cases decline.
let index_eq = if rowid_target.is_none()
&& rowid_in_list.is_none()
&& rowid_range.is_none()
&& rowid_eq_residual.is_none()
{
index_eq_residual_seek_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
let set_placeholder_count: u32 = stmt
.assignments
.iter()
.map(|a| count_anon_placeholders(&a.value))
.sum();
let where_placeholder_count: u32 = stmt
.where_clause
.as_ref()
.map_or(0, count_anon_placeholders);
let matched_rowid_reg;
let apply_done_label = b.emit_label();
let (apply_seek_miss_label, apply_loop) = if let Some(target_expr) = rowid_target {
matched_rowid_reg = b.alloc_reg();
b.set_next_anon_placeholder(set_placeholder_count + 1);
emit_expr(b, target_expr, matched_rowid_reg, None);
// Coerce a non-integer-literal rowid key (placeholder / real / text) to INTEGER affinity: a
// non-exact key (2.5 / 'abc' / NULL) rejects to `apply_done_label` (no row updated) instead of
// raw `SeekRowid` TRUNCATING it to a wrong rowid (`WHERE id = 2.5` must not update row 2).
// Integer literal is exact -> no MustBeInt (byte-identical). Mirrors the DELETE / SELECT paths.
if !matches!(target_expr, Expr::Literal(Literal::Integer(_), _)) {
b.emit_jump_to_label(
Opcode::MustBeInt,
matched_rowid_reg,
0,
apply_done_label,
P4::None,
0,
);
}
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
matched_rowid_reg,
apply_done_label,
P4::None,
0,
);
(None, None)
} else {
let rowset_reg = b.alloc_reg();
matched_rowid_reg = b.alloc_reg();
let collect_done_label = b.emit_label();
// Pass 1: collect matching rowids before mutating the table cursor.
if let Some((values, has_residual)) = &rowid_in_list {
// bd-update-rowid-in: one `SeekRowid` per listed value; a miss skips to the next value. When a
// residual is present it is re-applied per seeked row, numbered from `set_placeholder_count + 1`
// (after the SET placeholders, which are emitted in Pass 2) and reset each iteration so a `?`
// in the residual numbers identically. The values are sorted+deduped and the RowSet dedups again.
for &value in values {
let next_value = b.emit_label();
b.emit_op(Opcode::Int64, 0, matched_rowid_reg, 0, P4::Int64(value), 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
matched_rowid_reg,
next_value,
P4::None,
0,
);
if *has_residual && let Some(where_expr) = &stmt.where_clause {
b.set_next_anon_placeholder(set_placeholder_count + 1);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
next_value,
);
}
b.emit_op(
Opcode::RowSetAdd,
rowset_reg,
matched_rowid_reg,
0,
P4::None,
0,
);
b.resolve_label(next_value);
}
} else if let Some(range) = rowid_range {
emit_rowid_range_rowset_collect(
b,
table,
stmt.table.alias.as_deref(),
schema,
table_cursor,
rowset_reg,
matched_rowid_reg,
collect_done_label,
range,
);
} else if let Some(target_expr) = rowid_eq_residual {
// Seek the single target row; a miss or a residual failure jumps to collect_done (no update).
// The `rowid = <const>` probe and the re-applied residual both number from
// `set_placeholder_count + 1` (after the SET placeholders, emitted in Pass 2).
b.set_next_anon_placeholder(set_placeholder_count + 1);
emit_expr(b, target_expr, matched_rowid_reg, None);
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
matched_rowid_reg,
collect_done_label,
P4::None,
0,
);
if let Some(where_expr) = &stmt.where_clause {
b.set_next_anon_placeholder(set_placeholder_count + 1);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
collect_done_label,
);
}
b.emit_op(
Opcode::RowSetAdd,
rowset_reg,
matched_rowid_reg,
0,
P4::None,
0,
);
} else if let Some((idx_schema, target, aff, has_residual)) = index_eq {
// Fresh read cursor beyond the table + registered index-maintenance cursors. The probe and the
// residual (if any) number from set_placeholder_count + 1, after the SET placeholders (Pass 2).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_read_cursor = table_cursor + 1 + table.indexes.len() as i32;
emit_index_eq_rowset_collect(
b,
idx_read_cursor,
idx_schema,
target,
aff,
rowset_reg,
matched_rowid_reg,
table,
stmt.table.alias.as_deref(),
schema,
table_cursor,
stmt.where_clause.as_ref(),
set_placeholder_count + 1,
has_residual,
);
} else {
let collect_start = b.current_addr();
b.emit_jump_to_label(
Opcode::Rewind,
table_cursor,
0,
collect_done_label,
P4::None,
0,
);
let collect_skip_label = b.emit_label();
if let Some(where_expr) = &stmt.where_clause {
b.set_next_anon_placeholder(set_placeholder_count + 1);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
collect_skip_label,
);
}
b.emit_op(
Opcode::Rowid,
table_cursor,
matched_rowid_reg,
0,
P4::None,
0,
);
b.emit_op(
Opcode::RowSetAdd,
rowset_reg,
matched_rowid_reg,
0,
P4::None,
0,
);
b.resolve_label(collect_skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let collect_body = (collect_start + 1) as i32;
b.emit_op(Opcode::Next, table_cursor, collect_body, 0, P4::None, 0);
}
b.resolve_label(collect_done_label);
// Pass 2: revisit each matched rowid and perform the delete+insert rewrite.
let apply_loop = b.current_addr();
b.emit_jump_to_label(
Opcode::RowSetRead,
rowset_reg,
matched_rowid_reg,
apply_done_label,
P4::None,
0,
);
let apply_seek_miss_label = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
matched_rowid_reg,
apply_seek_miss_label,
P4::None,
0,
);
(Some(apply_seek_miss_label), Some(apply_loop))
};
// Read ALL existing columns into registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let col_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + i as i32;
if table.columns.get(i).is_some_and(|col| col.is_ipk) {
// INTEGER PRIMARY KEY columns alias rowid and are not stored in
// the record payload. Materialize from Rowid so unchanged UPDATE
// rewrites preserve the original key instead of generating a new
// rowid.
b.emit_op(Opcode::Rowid, table_cursor, target_reg, 0, P4::None, 0);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
table_cursor,
i as i32,
target_reg,
P4::None,
0,
);
}
}
// Evaluate new values from AST expressions and overwrite changed columns.
// A ScanCtx is required so that column references in SET expressions
// (e.g., `SET val = val + 5`) resolve to the cursor's current row.
//
// NOTE: index deletes and the row Delete are deferred until AFTER constraint
// validation below. Index key terms and the old indexed values are read
// straight from the table cursor (which still points at the unchanged old
// row), so deferring them is safe — and it is *required* so that an
// `UPDATE OR IGNORE` whose new row fails a CHECK / NOT NULL constraint can
// skip the row without having already destroyed it (matching C SQLite,
// which validates constraints before applying any mutation).
let update_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: stmt.table.alias.as_deref(),
schema: Some(schema),
register_base: None,
secondaries: &[],
};
// Reset placeholder counter to 1 for SET expressions (they appear first in SQL text).
b.set_next_anon_placeholder(1);
emit_update_assignments(b, &stmt.assignments, table, col_regs, &update_ctx)?;
// Recompute STORED generated columns and validate constraints on the NEW
// row image BEFORE any destructive mutation, so OR IGNORE can bail out
// cleanly. (A second `emit_stored_generated_columns` is intentionally NOT
// emitted later — the values computed here are reused for MakeRecord.)
emit_stored_generated_columns(b, table, col_regs);
// For UPDATE OR IGNORE, a failing CHECK or NOT NULL constraint must skip
// this row silently rather than aborting the statement (C SQLite
// semantics). Route the skip to the next matched row: the full-scan apply
// loop re-enters via `apply_seek_miss_label` (which Gotos `apply_loop`),
// while a single rowid-target update simply jumps to `apply_done_label`.
let constraint_ignore_label =
if matches!(stmt.or_conflict.as_ref(), Some(ConflictAction::Ignore)) {
Some(apply_seek_miss_label.unwrap_or(apply_done_label))
} else {
None
};
emit_strict_type_check(b, table, col_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_check_constraints(b, table, col_regs, constraint_ignore_label);
emit_not_null_constraints(
b,
table,
col_regs,
stmt.or_conflict,
constraint_ignore_label,
);
// Constraints passed: now perform the destructive delete+insert rewrite.
// Index maintenance (bd-2f9t): Delete OLD index entries. The indexed key
// terms are re-read from the table cursor's still-current old row.
emit_index_deletes_for_update(b, table, table_cursor, &update_index_mask);
// UPDATE is delete+insert: remove the current row first, then insert the
// rewritten record (possibly at a new rowid).
b.emit_op(
Opcode::Delete,
table_cursor,
0,
0,
P4::None,
OPFLAG_ISUPDATE,
);
// Determine destination rowid for re-insertion.
let mut rowid_reg = matched_rowid_reg;
let rowid_alias_col_idx = ctx
.rowid_alias_col_idx
.or_else(|| table.columns.iter().position(|col| col.is_ipk));
if let Some(ipk_idx) = rowid_alias_col_idx {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let ipk_reg = col_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let rowid_done_label = b.emit_label();
let concurrent_flag = i32::from(ctx.concurrent_mode);
rowid_reg = b.alloc_reg();
// If the rewritten IPK is NULL, allocate a new rowid.
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, rowid_done_label, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
table_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
// Keep the IPK payload column consistent with the chosen rowid.
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(rowid_done_label);
}
// STORED generated columns and constraint validation already ran above on
// the new row image (before the destructive delete), so MakeRecord can use
// col_regs directly.
// Apply column type affinities before packing the record.
let aff_str = table.affinity_string();
let rec_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let n_cols_i32 = n_cols as i32;
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols_i32,
0,
P4::Affinity(aff_str.clone()),
0,
);
b.emit_op(
Opcode::MakeRecord,
col_regs,
n_cols_i32,
rec_reg,
P4::Affinity(aff_str),
0,
);
// Conflict resolution for UPDATE: use explicit OR clause if present,
// otherwise default to OE_ABORT (standard UPDATE raises constraint error
// on PK/UNIQUE conflicts rather than silently replacing).
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
b.emit_op(
Opcode::Insert,
table_cursor,
rec_reg,
rowid_reg,
P4::Table(table.name.clone()),
oe_flag | OPFLAG_ISUPDATE,
);
// Index maintenance (bd-2f9t): Insert NEW index entries after table insert.
// col_regs now contains NEW column values.
emit_index_inserts_for_update(
b,
table,
table_cursor,
col_regs,
rowid_reg,
stmt.or_conflict,
&update_index_mask,
);
// RETURNING clause: position cursor on updated row and read columns.
if !stmt.returning.is_empty() {
// GH #159: UPDATE OR IGNORE ... RETURNING must NOT emit a row whose
// insert the engine suppressed on a rowid/UNIQUE conflict — it already
// rolled the write back, so re-seeking `rowid_reg` here would wrongly
// emit either the un-updated row (same rowid) or the CONFLICTING row's
// data (new rowid). `constraint_ignore_label` is `Some` iff this is an
// OR IGNORE update; jump past RETURNING to the row-skip label when the
// engine's own conflict decision (`conflict_skip_idx`) is set.
if let Some(skip) = constraint_ignore_label {
b.emit_jump_to_label(Opcode::IfConflictSkip, 0, 0, skip, P4::None, 0);
}
// RETURNING appears after WHERE in SQL textual order; restore the
// post-WHERE placeholder index so RETURNING placeholders don't collide
// with SET placeholder numbering.
b.set_next_anon_placeholder(set_placeholder_count + where_placeholder_count + 1);
emit_returning(
b,
table_cursor,
table,
&stmt.returning,
stmt.table.alias.as_deref(),
rowid_reg,
)?;
}
if let (Some(apply_seek_miss_label), Some(apply_loop)) = (apply_seek_miss_label, apply_loop) {
b.resolve_label(apply_seek_miss_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let apply_loop_addr = apply_loop as i32;
b.emit_op(Opcode::Goto, 0, apply_loop_addr, 0, P4::None, 0);
}
// Done: Close index cursors, then table cursor.
b.resolve_label(apply_done_label);
b.resolve_label(done_label);
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for idx_offset in 0..table.indexes.len() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, table_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End label.
b.resolve_label(end_label);
Ok(())
}
fn collect_update_assignment_columns(
table: &TableSchema,
assignments: &[fsqlite_ast::Assignment],
) -> Result<Vec<usize>, CodegenError> {
let mut columns = Vec::with_capacity(assignments.len());
for assignment in assignments {
match &assignment.target {
AssignmentTarget::Column(name) => {
columns.push(table.column_index(name).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
}
})?);
}
AssignmentTarget::ColumnList(names) => {
if names.is_empty() {
return Err(CodegenError::Unsupported(
"multi-column SET requires at least one target column".to_owned(),
));
}
for name in names {
columns.push(table.column_index(name).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
}
})?);
}
}
}
}
Ok(columns)
}
/// Number of statically-known result columns in a subquery used as the RHS of a
/// row-value UPDATE `SET (a, b) = (SELECT ...)`. Returns `None` for shapes whose
/// arity is not knowable here (compound SELECT, `VALUES`, or `*` / `table.*`
/// result columns), which the caller turns into a clear diagnostic.
fn row_value_subquery_arity(select: &SelectStatement) -> Option<usize> {
if !select.body.compounds.is_empty() {
return None;
}
let SelectCore::Select { columns, .. } = &select.body.select else {
return None;
};
if columns
.iter()
.any(|c| !matches!(c, ResultColumn::Expr { .. }))
{
return None;
}
Some(columns.len())
}
/// Project a row-value subquery down to its `col_idx`-th result column so it can
/// be emitted as a scalar subquery feeding a single UPDATE `SET` target. The
/// FROM/WHERE/ORDER BY/LIMIT are preserved verbatim, so each projected column is
/// drawn from the same (first) row and correlation against the outer UPDATE row
/// is retained. Returns `None` for the same unsupported shapes as
/// [`row_value_subquery_arity`].
fn project_row_value_subquery_column(
select: &SelectStatement,
col_idx: usize,
) -> Option<SelectStatement> {
if !select.body.compounds.is_empty() {
return None;
}
let SelectCore::Select { columns, .. } = &select.body.select else {
return None;
};
let target = columns.get(col_idx)?;
if !matches!(target, ResultColumn::Expr { .. }) {
return None;
}
let target = target.clone();
let mut projected = select.clone();
if let SelectCore::Select { columns, .. } = &mut projected.body.select {
*columns = vec![target];
}
Some(projected)
}
fn emit_update_assignments(
b: &mut ProgramBuilder,
assignments: &[fsqlite_ast::Assignment],
table: &TableSchema,
col_regs: i32,
scan: &ScanCtx<'_>,
) -> Result<(), CodegenError> {
for assignment in assignments {
match &assignment.target {
AssignmentTarget::Column(name) => {
let col_idx =
table
.column_index(name)
.ok_or_else(|| CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
})?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + col_idx as i32;
emit_expr(b, &assignment.value, target_reg, Some(scan));
}
AssignmentTarget::ColumnList(names) => match &assignment.value {
// `SET (a, b) = (e1, e2)` — parenthesized row-value of scalars.
Expr::RowValue(values, _) => {
if names.len() != values.len() {
return Err(CodegenError::Unsupported(format!(
"multi-column SET arity mismatch: {} targets, {} values",
names.len(),
values.len()
)));
}
for (name, value) in names.iter().zip(values) {
let col_idx = table.column_index(name).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
}
})?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + col_idx as i32;
emit_expr(b, value, target_reg, Some(scan));
}
}
// `SET (a, b) = (SELECT a, b FROM ...)` — a row-value whose RHS is
// a (possibly correlated) subquery. Each target column is fed by
// the matching output column of the subquery; project the subquery
// one column at a time and reuse the scalar-subquery emitter, which
// resolves correlation against the outer UPDATE row via `scan`.
// bd-6utze.
Expr::Subquery(select, _) => {
let arity = row_value_subquery_arity(select).ok_or_else(|| {
CodegenError::Unsupported(
"multi-column SET subquery source must be a single SELECT with \
explicit result columns (no '*' or compound SELECT)"
.to_owned(),
)
})?;
if names.len() != arity {
return Err(CodegenError::Unsupported(format!(
"multi-column SET arity mismatch: {} targets, {} subquery columns",
names.len(),
arity
)));
}
for (col_pos, name) in names.iter().enumerate() {
let col_idx = table.column_index(name).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
}
})?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + col_idx as i32;
let projected = project_row_value_subquery_column(select, col_pos)
.expect("subquery shape validated by row_value_subquery_arity");
if let Some(schema) = scan.schema {
emit_scalar_subquery(b, &projected, target_reg, scan, schema);
} else {
b.emit_op(Opcode::Null, 0, target_reg, 0, P4::None, 0);
}
}
}
// A single-target list `(v) = <scalar>` is equivalent to
// `v = <scalar>`. This also catches `(v) = (SELECT ...)` whose
// uncorrelated scalar subquery the DML rewrite pass already folded
// to a literal before codegen (so it arrives as a plain expr).
scalar if names.len() == 1 => {
let name = &names[0];
let col_idx =
table
.column_index(name)
.ok_or_else(|| CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.to_owned(),
})?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + col_idx as i32;
emit_expr(b, scalar, target_reg, Some(scan));
}
_ => {
return Err(CodegenError::Unsupported(
"multi-column SET requires a row-value or subquery expression".to_owned(),
));
}
},
}
}
Ok(())
}
fn update_index_maintenance_mask(table: &TableSchema, assignment_cols: &[usize]) -> Vec<bool> {
table
.indexes
.iter()
.map(|index| update_must_maintain_index(table, index, assignment_cols))
.collect()
}
fn update_must_maintain_index(
table: &TableSchema,
index: &IndexSchema,
assignment_cols: &[usize],
) -> bool {
if assignment_cols
.iter()
.any(|col_idx| table.columns.get(*col_idx).is_some_and(|col| col.is_ipk))
{
return true;
}
if !index.supports_direct_column_lookup() {
return true;
}
if table
.columns
.iter()
.any(|col| col.generated_stored.is_some())
{
return true;
}
index.columns.iter().any(|column| {
table
.column_index(column)
.is_none_or(|col_idx| assignment_cols.contains(&col_idx))
})
}
// ---------------------------------------------------------------------------
// UPDATE ... FROM codegen
// ---------------------------------------------------------------------------
/// Emit a Column or Rowid opcode for a column reference against a specific cursor.
fn emit_column_from_cursor(
b: &mut ProgramBuilder,
col_name: &str,
cursor: i32,
table: &TableSchema,
reg: i32,
) {
if let Some(col_idx) = table.column_index(col_name) {
if table.columns[col_idx].is_ipk {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(Opcode::Column, cursor, col_idx as i32, reg, P4::None, 0);
}
} else if table.resolves_to_hidden_rowid(col_name) {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
}
/// Emit a column read against a [`SecondaryScan`], honoring a register-backed
/// source when present. A cursor-backed secondary (UPDATE ... FROM) reads the
/// B-tree; a register-backed secondary (the UPSERT `excluded.*` pseudo-row,
/// bd-xjfrt) copies the attempted-insert value from `register_base + col_idx`.
/// The IPK column register is kept in sync with the rowid at INSERT codegen, so
/// a plain copy is correct for it too.
fn emit_secondary_column(b: &mut ProgramBuilder, col_name: &str, sec: &SecondaryScan<'_>, reg: i32) {
if let Some(base) = sec.register_base {
if let Some(col_idx) = sec.table.column_index(col_name) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(Opcode::Copy, base + col_idx as i32, reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
} else {
emit_column_from_cursor(b, col_name, sec.cursor, sec.table, reg);
}
}
/// Generate VDBE bytecode for `UPDATE target SET ... FROM source WHERE ...`.
///
/// Uses a nested-loop join: outer loop scans the FROM table, inner loop scans
/// the target table. WHERE clause filters for the join condition.
#[allow(clippy::too_many_lines)]
fn codegen_update_from(
b: &mut ProgramBuilder,
stmt: &UpdateStatement,
from_clause: &FromClause,
schema: &[TableSchema],
ctx: &CodegenContext,
) -> Result<(), CodegenError> {
use fsqlite_ast::{JoinConstraint, JoinKind};
// Resolve a single FROM source to (name, alias). Only named tables are
// supported in VDBE codegen: a single `FROM (subquery)` is flattened to a
// named table before codegen (try_flatten_update_from_subquery in
// connection.rs), and codegen has no general FROM-relation materialization.
fn resolve_from_table(src: &TableOrSubquery) -> Result<(&str, Option<&str>), CodegenError> {
match src {
TableOrSubquery::Table { name, alias, .. } => {
Ok((name.name.as_str(), alias.as_deref()))
}
TableOrSubquery::Subquery { .. } => Err(CodegenError::Unsupported(
"UPDATE ... FROM subquery sources are not yet supported (only named tables; \
a single FROM (subquery) is flattened before codegen)"
.to_owned(),
)),
_ => Err(CodegenError::Unsupported(
"UPDATE ... FROM only supports named tables".to_owned(),
)),
}
}
// Collect every FROM source (the leading source plus comma/JOIN sources)
// and the ON conditions of any joins. Comma sources and CROSS/INNER joins
// form a cross product filtered by (ON conditions AND WHERE); this matches
// SQLite's UPDATE ... FROM semantics for inner joins.
let mut from_specs: Vec<(&str, Option<&str>)> = Vec::with_capacity(1 + from_clause.joins.len());
from_specs.push(resolve_from_table(&from_clause.source)?);
let mut on_conditions: Vec<&Expr> = Vec::new();
for join in &from_clause.joins {
if join.join_type.natural {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with NATURAL JOIN is not yet supported".to_owned(),
));
}
match join.join_type.kind {
JoinKind::Inner | JoinKind::Cross => {}
JoinKind::Left | JoinKind::Right | JoinKind::Full => {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with an OUTER JOIN source is not yet supported".to_owned(),
));
}
}
from_specs.push(resolve_from_table(&join.table)?);
match &join.constraint {
Some(JoinConstraint::On(expr)) => on_conditions.push(expr),
Some(JoinConstraint::Using(_)) => {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with a USING(...) join constraint is not yet supported"
.to_owned(),
));
}
None => {}
}
}
let table_name = table_name_from_qualified(&stmt.table);
let target = find_table(schema, table_name)?;
let n_cols = target.columns.len();
// Resolve each FROM source to its schema and assign a read cursor.
// Cursor allocation: 0 = target (write), 1..=K = target indexes,
// K+1.. = one read cursor per FROM source.
let target_cursor = 0_i32;
let n_indexes = target.indexes.len();
let mut secondaries: Vec<SecondaryScan> = Vec::with_capacity(from_specs.len());
for (i, (src_name, src_alias)) in from_specs.iter().enumerate() {
let src_table = find_table(schema, src_name)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let cursor = (1 + n_indexes + i) as i32;
secondaries.push(SecondaryScan {
cursor,
table: src_table,
table_alias: *src_alias,
register_base: None,
});
}
let end_label = b.emit_label();
// Init.
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (write).
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
// Validate assignment targets before emitting loops.
collect_update_assignment_columns(target, &stmt.assignments)?;
// Scan context with all FROM sources for multi-table column resolution.
let scan = ScanCtx {
cursor: target_cursor,
table: target,
table_alias: stmt.table.alias.as_deref(),
schema: Some(schema),
register_base: None,
secondaries: &secondaries,
};
for assign in &stmt.assignments {
validate_scan_expr_columns(&assign.value, &scan)?;
}
for cond in &on_conditions {
validate_scan_expr_columns(cond, &scan)?;
}
if let Some(where_expr) = &stmt.where_clause {
validate_scan_expr_columns(where_expr, &scan)?;
}
validate_single_table_result_columns(
&stmt.returning,
target,
stmt.table.alias.as_deref(),
stmt.table.name.schema.as_deref(),
)?;
// OpenWrite for target table.
b.emit_op(
Opcode::OpenWrite,
target_cursor,
target.root_page,
0,
P4::Table(target.name.clone()),
0,
);
// OpenWrite for each index on target.
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (idx_offset, index) in target.indexes.iter().enumerate() {
let idx_cursor = target_cursor + 1 + idx_offset as i32;
b.emit_op(
Opcode::OpenWrite,
idx_cursor,
index.root_page,
0,
P4::Table(index.name.clone()),
0,
);
}
register_table_index_meta(b, target, target_cursor);
// OpenRead for each FROM source.
for sec in &secondaries {
b.emit_op(
Opcode::OpenRead,
sec.cursor,
sec.table.root_page,
0,
P4::Table(sec.table.name.clone()),
0,
);
}
// Emit one nested scan loop per FROM source (outermost = first source),
// recording each loop's body address (Next target) and done label.
struct LoopFrame {
cursor: i32,
body: i32,
done: Label,
}
let mut frames: Vec<LoopFrame> = Vec::with_capacity(secondaries.len());
for sec in &secondaries {
let done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, sec.cursor, 0, done, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let body = b.current_addr() as i32;
frames.push(LoopFrame {
cursor: sec.cursor,
body,
done,
});
}
// Innermost loop: scan target table.
let target_done_label = b.emit_label();
b.emit_jump_to_label(
Opcode::Rewind,
target_cursor,
0,
target_done_label,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_body = b.current_addr() as i32;
// Count anonymous placeholders so each clause numbers from the right base.
// SQL textual order: SET, then FROM (ON conditions), then WHERE, then
// RETURNING.
let set_placeholder_count: u32 = stmt
.assignments
.iter()
.map(|a| count_anon_placeholders(&a.value))
.sum();
let on_placeholder_count: u32 = on_conditions
.iter()
.map(|e| count_anon_placeholders(e))
.sum();
let where_placeholder_count: u32 = stmt
.where_clause
.as_ref()
.map_or(0, count_anon_placeholders);
// Combined filter: each ON condition (in join order) then the WHERE clause.
// Any failed condition jumps to skip_label (the innermost loop's Next).
let skip_label = b.emit_label();
let filter_conditions: Vec<&Expr> = on_conditions
.iter()
.copied()
.chain(stmt.where_clause.as_ref())
.collect();
if !filter_conditions.is_empty() {
// Placeholders in ON/WHERE follow the SET placeholders textually.
b.set_next_anon_placeholder(set_placeholder_count + 1);
for cond in &filter_conditions {
let cond_reg = b.alloc_temp();
emit_expr(b, cond, cond_reg, Some(&scan));
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
b.free_temp(cond_reg);
}
}
// Read ALL existing columns from target into registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let col_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let target_reg = col_regs + i as i32;
if target.columns.get(i).is_some_and(|col| col.is_ipk) {
b.emit_op(Opcode::Rowid, target_cursor, target_reg, 0, P4::None, 0);
} else {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
target_cursor,
i as i32,
target_reg,
P4::None,
0,
);
}
}
// Evaluate SET assignments. Reset placeholder counter to 1 (SET first in SQL
// text). The scan cursor still points at the OLD row here, so `SET x = x + 1`
// observes the pre-update value.
b.set_next_anon_placeholder(1);
emit_update_assignments(b, &stmt.assignments, target, col_regs, &scan)?;
// Capture the old rowid before any destructive mutation (re-insertion base).
let old_rowid_reg = b.alloc_reg();
b.emit_op(Opcode::Rowid, target_cursor, old_rowid_reg, 0, P4::None, 0);
// Recompute STORED generated columns, then validate CHECK / NOT NULL on the
// NEW row image BEFORE any destructive mutation — so an `UPDATE OR IGNORE
// ... FROM ...` whose new row fails a constraint can skip the row WITHOUT
// having already deleted the old one (bd-xoixz). This mirrors the plain
// `codegen_update` path, which defers the index deletes + row Delete until
// after constraint validation. `constraint_ignore_label` routes a violation
// to the innermost loop's Next (`skip_label`); the FROM-path uniqueness /
// RETURNING conflict skip already uses this same label (IfConflictSkip).
emit_stored_generated_columns(b, target, col_regs);
emit_strict_type_check(b, target, col_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols as i32,
0,
P4::Affinity(target.affinity_string()),
0,
);
let constraint_ignore_label =
if matches!(stmt.or_conflict.as_ref(), Some(ConflictAction::Ignore)) {
Some(skip_label)
} else {
None
};
emit_check_constraints(b, target, col_regs, constraint_ignore_label);
emit_not_null_constraints(
b,
target,
col_regs,
stmt.or_conflict,
constraint_ignore_label,
);
// Constraints passed: NOW perform the destructive delete+insert. Old index
// entries are read from the cursor (still positioned on the unchanged old
// row) before the row Delete.
emit_index_deletes(b, target, target_cursor);
b.emit_op(
Opcode::Delete,
target_cursor,
0,
0,
P4::None,
OPFLAG_ISUPDATE,
);
// Determine destination rowid.
let mut rowid_reg = old_rowid_reg;
let rowid_alias_col_idx = ctx
.rowid_alias_col_idx
.or_else(|| target.columns.iter().position(|col| col.is_ipk));
if let Some(ipk_idx) = rowid_alias_col_idx {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let ipk_reg = col_regs + ipk_idx as i32;
let auto_label = b.emit_label();
let rowid_done_label = b.emit_label();
let concurrent_flag = i32::from(ctx.concurrent_mode);
rowid_reg = b.alloc_reg();
b.emit_jump_to_label(Opcode::IsNull, ipk_reg, 0, auto_label, P4::None, 0);
b.emit_op(Opcode::Copy, ipk_reg, rowid_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, rowid_done_label, P4::None, 0);
b.resolve_label(auto_label);
b.emit_op(
Opcode::NewRowid,
target_cursor,
rowid_reg,
concurrent_flag,
P4::None,
0,
);
b.emit_op(Opcode::Copy, rowid_reg, ipk_reg, 0, P4::None, 0);
b.resolve_label(rowid_done_label);
}
let aff_str = target.affinity_string();
let rec_reg = b.alloc_reg();
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let n_cols_i32 = n_cols as i32;
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols_i32,
0,
P4::Affinity(aff_str.clone()),
0,
);
b.emit_op(
Opcode::MakeRecord,
col_regs,
n_cols_i32,
rec_reg,
P4::Affinity(aff_str),
0,
);
// Insert updated row.
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
b.emit_op(
Opcode::Insert,
target_cursor,
rec_reg,
rowid_reg,
P4::Table(target.name.clone()),
oe_flag | OPFLAG_ISUPDATE,
);
// Insert new index entries.
emit_index_inserts(
b,
target,
target_cursor,
col_regs,
rowid_reg,
stmt.or_conflict,
);
// RETURNING clause (numbered after SET + ON + WHERE placeholders).
if !stmt.returning.is_empty() {
// GH #159: as in the plain UPDATE path, an OR IGNORE row whose insert
// the engine suppressed on a rowid/UNIQUE conflict must not emit a
// RETURNING row. Jump past RETURNING to the loop's skip label when the
// engine's conflict_skip_idx is set.
if matches!(stmt.or_conflict.as_ref(), Some(ConflictAction::Ignore)) {
b.emit_jump_to_label(Opcode::IfConflictSkip, 0, 0, skip_label, P4::None, 0);
}
b.set_next_anon_placeholder(
set_placeholder_count + on_placeholder_count + where_placeholder_count + 1,
);
emit_returning(
b,
target_cursor,
target,
&stmt.returning,
stmt.table.alias.as_deref(),
rowid_reg,
)?;
}
// Skip label for filtered-out rows.
b.resolve_label(skip_label);
// Innermost (target) Next: loop back to the target loop body.
b.emit_op(Opcode::Next, target_cursor, target_body, 0, P4::None, 0);
b.resolve_label(target_done_label);
// Unwind the FROM-source loops from innermost to outermost. Each loop's
// Next jumps back to its body; its done label lands here so an exhausted or
// empty source falls through to the next-outer loop's Next.
for frame in frames.iter().rev() {
b.emit_op(Opcode::Next, frame.cursor, frame.body, 0, P4::None, 0);
b.resolve_label(frame.done);
}
// Close all cursors.
for sec in &secondaries {
b.emit_op(Opcode::Close, sec.cursor, 0, 0, P4::None, 0);
}
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for idx_offset in 0..target.indexes.len() {
let idx_cursor = target_cursor + 1 + idx_offset as i32;
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Close, target_cursor, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End label.
b.resolve_label(end_label);
Ok(())
}
// ---------------------------------------------------------------------------
// DELETE codegen
// ---------------------------------------------------------------------------
/// True when every present bound of `range` is an integer literal — so a Pass-1 rowid-range collection
/// emits the bounds as constants (no anonymous placeholders) and the seek+stop bounds are exact (no WHERE
/// filter needed). bd-delete-rowid-range / bd-update-rowid-range.
fn rowid_range_bounds_are_int_literals(range: &RowidRangeTarget<'_>) -> bool {
let is_int = |bound: Option<RowidRangeBound<'_>>| {
bound.is_none_or(|b| matches!(b.expr, Expr::Literal(Literal::Integer(_), _)))
};
is_int(range.lower) && is_int(range.upper)
}
/// DELETE/UPDATE Pass-1 rowid-range collection: position at the lower bound (`SeekGE`/`SeekGT`, or
/// `Rewind` when unbounded below), walk forward adding each rowid to the RowSet, and stop once past the
/// upper bound (`Gt`/`Ge`). Mirrors the ascending walk of `codegen_select_rowid_range_scan` but collects
/// rowids instead of emitting rows. Requires integer-literal bounds (`rowid_range_bounds_are_int_literals`),
/// so it emits no anonymous placeholders and needs no WHERE filter (the bounds are exact). The caller
/// resolves `collect_done_label` after this returns (the trailing `Next` falls through to it on
/// exhaustion). bd-delete-rowid-range / bd-update-rowid-range.
#[allow(clippy::too_many_arguments)]
fn emit_rowid_range_rowset_collect(
b: &mut ProgramBuilder,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
table_cursor: i32,
rowset_reg: i32,
rowid_reg: i32,
collect_done_label: crate::Label,
range: RowidRangeTarget<'_>,
) {
let lower_reg = range.lower.map(|bound| {
let r = b.alloc_reg();
emit_expr(b, bound.expr, r, None);
r
});
let upper_reg = range.upper.map(|bound| {
let r = b.alloc_reg();
emit_expr(b, bound.expr, r, None);
r
});
let upper_comparison = range
.upper
.map(|bound| resolved_rowid_range_comparison(table, table_alias, schema, bound));
let collect_start = b.current_addr();
if let Some(bound) = range.lower {
let seek_opcode = if bound.inclusive {
Opcode::SeekGE
} else {
Opcode::SeekGT
};
b.emit_jump_to_label(
seek_opcode,
table_cursor,
lower_reg.expect("lower bound register should exist"),
collect_done_label,
P4::None,
0,
);
} else {
b.emit_jump_to_label(
Opcode::Rewind,
table_cursor,
0,
collect_done_label,
P4::None,
0,
);
}
// Loop body (collect_start + 1): read the rowid, stop once past the upper bound, else add it.
b.emit_op(Opcode::Rowid, table_cursor, rowid_reg, 0, P4::None, 0);
if let Some(bound) = range.upper {
let stop_opcode = if bound.inclusive {
Opcode::Gt
} else {
Opcode::Ge
};
b.emit_jump_to_label(
stop_opcode,
upper_reg.expect("upper bound register should exist"),
rowid_reg,
collect_done_label,
upper_comparison
.as_ref()
.map_or(P4::None, |c| c.collation_p4.clone()),
upper_comparison.as_ref().map_or(0, |c| c.cmp_p5),
);
}
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let collect_body = (collect_start + 1) as i32;
b.emit_op(Opcode::Next, table_cursor, collect_body, 0, P4::None, 0);
}
/// `(index, target)` for a DELETE/UPDATE `WHERE <col> = <value>` served by a single-column ASCENDING
/// INTEGER-affinity ('D') index. The value is a literal OR a placeholder (`is_simple_constant`): the
/// collect coerces the probe to the column's affinity (`Opcode::Affinity`, like the index-range path) so
/// a runtime-typed bound seeks identically to the full-scan filter — the seek is then authoritative with
/// no affinity fallback. Bare eq only (`extract_column_eq_target` needs a top-level `col = <expr>`). The
/// rowid case is handled earlier and the IPK has no secondary index, so this never fires for it.
/// bd-delete-index-eq / bd-update-index-eq.
fn index_eq_seek_target<'a, 't>(
where_clause: Option<&'a Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
) -> Option<(&'t IndexSchema, &'a Expr, char)> {
let (col_name, target_expr) = extract_column_eq_target(where_clause, table, table_alias)?;
if !is_simple_constant(target_expr) {
return None;
}
let col_idx = table.column_index(&col_name)?;
// INTEGER ('D'), NUMERIC ('C'), REAL ('E'), or BINARY-TEXT ('B') affinity: the collect coerces the
// probe to this affinity so a runtime-typed bound seeks like the affinity-applying full-scan filter.
let affinity = table.columns.get(col_idx)?.affinity;
if !matches!(affinity, 'D' | 'C' | 'E' | 'B') {
return None;
}
let idx = table.indexes.iter().find(|idx| {
idx.supports_direct_column_lookup()
&& idx.key_term_count() == 1
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&col_name))
})?;
// A TEXT column's seek comparison depends on collation; only BINARY agrees with the coerced probe and
// the full-scan filter, so a NOCASE/RTRIM index declines (a numeric column has no collation concern).
if affinity == 'B'
&& !idx
.key_term_collation(0)
.is_none_or(|c| c.eq_ignore_ascii_case("BINARY"))
{
return None;
}
Some((idx, target_expr, affinity))
}
/// `(index, target, affinity, has_residual)` — like [`index_eq_seek_target`] but also admits the eq as a
/// CONJUNCT alongside other predicates the index seek cannot enforce: `<col> = <value> AND <residual>`.
/// The caller applies the full WHERE per candidate (via the table row) before adding it.
/// `has_residual == false` is the bare eq. bd-delete-index-eq-residual / bd-update-index-eq-residual.
fn index_eq_residual_seek_target<'a, 't>(
where_clause: Option<&'a Expr>,
table: &'t TableSchema,
table_alias: Option<&str>,
) -> Option<(&'t IndexSchema, &'a Expr, char, bool)> {
if let Some((idx, target, aff)) = index_eq_seek_target(where_clause, table, table_alias) {
return Some((idx, target, aff, false));
}
let where_expr = where_clause?;
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
if conjuncts.len() < 2 {
return None;
}
for term in conjuncts {
if let Some((idx, target, aff)) = index_eq_seek_target(Some(term), table, table_alias) {
return Some((idx, target, aff, true));
}
}
None
}
/// DELETE/UPDATE Pass-1 collection for `WHERE <single-col-ASC-integer-indexed> = <value> [AND
/// <residual>]`: open a FRESH read cursor on the index (distinct from the table cursor and the registered
/// index-maintenance cursors, so Pass 2 is unaffected), seek `(val, i64::MIN)`, walk the equal-value run
/// adding each rowid to the RowSet, then close the cursor. The probe is coerced to the column's 'D'
/// affinity (`Opcode::Affinity`) when it is not already a numeric literal — so a runtime-typed placeholder
/// bound seeks identically to the full-scan filter and the seek is authoritative (no affinity fallback).
/// When `residual_filter` is true, each candidate is positioned on the table cursor and the full WHERE is
/// applied before `RowSetAdd` (no covering decision in a DML collect — the row is always read). Control
/// falls through to `collect_done` (resolved by the caller). bd-delete-index-eq(-residual) /
/// bd-update-index-eq(-residual).
#[allow(clippy::too_many_arguments)]
fn emit_index_eq_rowset_collect(
b: &mut ProgramBuilder,
idx_read_cursor: i32,
idx_schema: &IndexSchema,
target_expr: &Expr,
// The indexed column's affinity ('D' integer / 'C' numeric / 'E' real / 'B' binary-text) — coerced
// onto a runtime-typed probe so it seeks like the affinity-applying comparison would.
affinity: char,
rowset_reg: i32,
rowid_reg: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
table_cursor: i32,
where_clause: Option<&Expr>,
// Reset target for the probe's / residual's anon placeholders (`current_anon_placeholder()` for
// DELETE, `set_placeholder_count + 1` for UPDATE). The probe `col = ?` and the re-applied residual
// both number from this base.
where_placeholder_base: u32,
residual_filter: bool,
) {
let probe_key_regs = b.alloc_regs(2);
let min_rowid_reg = probe_key_regs + 1;
// Emit the probe value, numbering an anon placeholder from the base, and coerce a runtime-typed bound
// to the column's INTEGER affinity so the seek positions like the affinity-applying comparison would.
b.set_next_anon_placeholder(where_placeholder_base);
emit_expr(b, target_expr, probe_key_regs, None);
if !bound_matches_affinity(affinity, target_expr) {
b.emit_op(
Opcode::Affinity,
probe_key_regs,
1,
0,
P4::Affinity(affinity.to_string()),
0,
);
}
b.emit_op(Opcode::Int64, 0, min_rowid_reg, 0, P4::Int64(i64::MIN), 0);
let probe_record_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_key_regs,
2,
probe_record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::OpenRead,
idx_read_cursor,
idx_schema.root_page,
0,
P4::Index(idx_schema.name.clone()),
0,
);
let close_label = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekGE,
idx_read_cursor,
probe_record_reg,
close_label,
P4::None,
0,
);
let loop_top = b.current_addr();
let skip_label = b.emit_label();
let idx_key_reg = b.alloc_reg();
b.emit_op(Opcode::Column, idx_read_cursor, 0, idx_key_reg, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
probe_key_regs,
idx_key_reg,
close_label,
direct_lookup_index_comparison_p4(idx_schema),
0x10,
);
b.emit_op(Opcode::IdxRowid, idx_read_cursor, rowid_reg, 0, P4::None, 0);
if residual_filter && let Some(where_expr) = where_clause {
// Position the table cursor on the candidate row and apply the full WHERE; a residual miss skips
// to the next index entry (skip_label = the Next).
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
skip_label,
P4::None,
0,
);
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
table_alias,
schema,
skip_label,
);
}
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
b.resolve_label(skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = loop_top as i32;
b.emit_op(Opcode::Next, idx_read_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(close_label);
b.emit_op(Opcode::Close, idx_read_cursor, 0, 0, P4::None, 0);
}
/// Generate VDBE bytecode for a DELETE statement.
///
/// Uses a two-pass plan: collect exact matching rowids without mutating the table, then revisit those
/// rowids to maintain indexes, emit `RETURNING`, and delete. Equality, literal-IN, and lower-bounded
/// literal-range predicates use direct seeks; all other predicates use the general filtered table scan.
pub fn codegen_delete(
b: &mut ProgramBuilder,
stmt: &DeleteStatement,
schema: &[TableSchema],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let table_name = table_name_from_qualified(&stmt.table);
let table = find_table(schema, table_name)?;
if table.without_rowid {
return codegen_delete_without_rowid(b, stmt, table, schema, _ctx);
}
let table_cursor = 0_i32;
let end_label = b.emit_label();
let _done_label = b.emit_label();
if !stmt.order_by.is_empty() || stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"DELETE ORDER BY/LIMIT/OFFSET must be materialized before codegen".to_owned(),
));
}
if let Some(where_expr) = &stmt.where_clause {
validate_single_table_expr_columns(where_expr, table, stmt.table.alias.as_deref())?;
}
validate_single_table_result_columns(
&stmt.returning,
table,
stmt.table.alias.as_deref(),
stmt.table.name.schema.as_deref(),
)?;
// Init.
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
// Transaction (write).
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
// OpenWrite for table.
b.emit_op(
Opcode::OpenWrite,
table_cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
// OpenWrite for each index (bd-34se: Phase 5I.4).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for (idx_offset, index) in table.indexes.iter().enumerate() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(
Opcode::OpenWrite,
idx_cursor,
index.root_page,
0,
P4::Table(index.name.clone()),
0,
);
}
// Register table-to-index cursor metadata for REPLACE conflict resolution.
register_table_index_meta(b, table, table_cursor);
// Two-pass DELETE (matches C SQLite behavior):
// Pass 1: Scan table, evaluate WHERE, collect matching rowids into a RowSet.
// Pass 2: Iterate collected rowids, seek, and delete.
// This prevents WHERE subqueries from seeing partially-deleted state.
let rowset_reg = b.alloc_reg();
let rowid_reg = b.alloc_reg();
// Initialize rowset register to NULL.
b.emit_op(Opcode::Null, 0, rowset_reg, 0, P4::None, 0);
// --- Pass 1: collect matching rowids ---
let collect_done_label = b.emit_label();
let rowid_target = extract_rowid_target_expr(stmt.where_clause.as_ref(), Some(table), None);
// bd-delete-rowid-in / bd-delete-rowid-in-residual: `DELETE ... WHERE <rowid> IN (<int literals>)
// [AND <residual>]` collects the listed rows with one `SeekRowid` each instead of full-scanning; when a
// residual is present it is re-applied per seeked row before adding to the RowSet. After `rowid = const`.
let rowid_in_list = if rowid_target.is_none() {
extract_rowid_in_list_residual_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
// bd-delete-rowid-range: `DELETE ... WHERE <rowid> <range>` collects the [lower, upper] slice with a
// bounded seek+walk (SeekGE/SeekGT to the lower bound, stop past the upper) instead of full-scanning.
// Bare range only (no residual), integer-literal bounds only (so the walk emits no anon placeholders),
// and only after the eq / IN cases decline.
let rowid_range = if rowid_target.is_none() && rowid_in_list.is_none() {
extract_rowid_range_target(
stmt.where_clause.as_ref(),
Some(table),
stmt.table.alias.as_deref(),
)
.filter(|range| {
range.lower.is_some()
&& rowid_range_fast_path_is_safe(*range)
&& rowid_range_bounds_are_int_literals(range)
})
} else {
None
};
// bd-delete-rowid-eq-residual: `DELETE ... WHERE <rowid> = <const> AND <residual>` seeks the single
// target row and applies the residual, instead of full-scanning — the common compare-and-delete shape.
let rowid_eq_residual =
if rowid_target.is_none() && rowid_in_list.is_none() && rowid_range.is_none() {
extract_rowid_eq_residual_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
// bd-delete-index-eq(-residual): `DELETE ... WHERE <single-col-int-indexed> = <int> [AND <residual>]`
// seeks the index for the candidate rowids (fresh read cursor), applies the residual per candidate,
// instead of full-scanning. After all rowid cases decline.
let index_eq = if rowid_target.is_none()
&& rowid_in_list.is_none()
&& rowid_range.is_none()
&& rowid_eq_residual.is_none()
{
index_eq_residual_seek_target(
stmt.where_clause.as_ref(),
table,
stmt.table.alias.as_deref(),
)
} else {
None
};
if let Some(target_expr) = rowid_target {
emit_expr(b, target_expr, rowid_reg, None);
// Coerce a non-integer-literal rowid key (placeholder / real / text) to INTEGER affinity: a
// non-exact key (2.5 / 'abc' / NULL) rejects to `collect_done_label` (nothing collected -> no
// delete) instead of raw `SeekRowid` TRUNCATING it to a wrong rowid (`WHERE id = 2.5` must not
// delete row 2). Integer literal is exact -> no MustBeInt (byte-identical). Mirrors the SELECT
// rowid lookup + count/aggregate coerced seeks.
if !matches!(target_expr, Expr::Literal(Literal::Integer(_), _)) {
b.emit_jump_to_label(
Opcode::MustBeInt,
rowid_reg,
0,
collect_done_label,
P4::None,
0,
);
}
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
collect_done_label,
P4::None,
0,
);
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
} else if let Some((values, has_residual)) = rowid_in_list {
// One `SeekRowid` per listed value; a miss skips to the next value. When there is a residual, the
// full WHERE is re-applied per seeked row (the placeholder base is reset each iteration so a `?` in
// the residual numbers identically). The listed values are sorted+deduped and the RowSet dedups
// again. Pass 2 is unchanged.
let where_base = b.current_anon_placeholder();
for &value in &values {
let next_value = b.emit_label();
b.emit_op(Opcode::Int64, 0, rowid_reg, 0, P4::Int64(value), 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
next_value,
P4::None,
0,
);
if has_residual && let Some(where_expr) = &stmt.where_clause {
b.set_next_anon_placeholder(where_base);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
next_value,
);
}
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
b.resolve_label(next_value);
}
} else if let Some(range) = rowid_range {
emit_rowid_range_rowset_collect(
b,
table,
stmt.table.alias.as_deref(),
schema,
table_cursor,
rowset_reg,
rowid_reg,
collect_done_label,
range,
);
} else if let Some(target_expr) = rowid_eq_residual {
// Seek the single target row; a miss or a residual failure jumps to collect_done (nothing to
// delete). The placeholder base is reset before the re-applied WHERE so a `?` in the residual
// numbers identically to the `rowid = ?` probe.
let where_base = b.current_anon_placeholder();
emit_expr(b, target_expr, rowid_reg, None);
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
collect_done_label,
P4::None,
0,
);
if let Some(where_expr) = &stmt.where_clause {
b.set_next_anon_placeholder(where_base);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
collect_done_label,
);
}
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
} else if let Some((idx_schema, target, aff, has_residual)) = index_eq {
// Fresh read cursor beyond the table + registered index-maintenance cursors.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let idx_read_cursor = table_cursor + 1 + table.indexes.len() as i32;
let where_base = b.current_anon_placeholder();
emit_index_eq_rowset_collect(
b,
idx_read_cursor,
idx_schema,
target,
aff,
rowset_reg,
rowid_reg,
table,
stmt.table.alias.as_deref(),
schema,
table_cursor,
stmt.where_clause.as_ref(),
where_base,
has_residual,
);
} else {
let collect_start = b.current_addr();
b.emit_jump_to_label(
Opcode::Rewind,
table_cursor,
0,
collect_done_label,
P4::None,
0,
);
let collect_skip_label = b.emit_label();
if let Some(where_expr) = &stmt.where_clause {
emit_where_filter(
b,
where_expr,
table_cursor,
table,
stmt.table.alias.as_deref(),
schema,
collect_skip_label,
);
}
// Get rowid of matching row and add to rowset.
b.emit_op(Opcode::Rowid, table_cursor, rowid_reg, 0, P4::None, 0);
b.emit_op(Opcode::RowSetAdd, rowset_reg, rowid_reg, 0, P4::None, 0);
b.resolve_label(collect_skip_label);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let collect_body = (collect_start + 1) as i32;
b.emit_op(Opcode::Next, table_cursor, collect_body, 0, P4::None, 0);
}
b.resolve_label(collect_done_label);
// --- Pass 2: iterate rowset, seek, and delete ---
let delete_done_label = b.emit_label();
let delete_loop = b.current_addr();
b.emit_jump_to_label(
Opcode::RowSetRead,
rowset_reg,
rowid_reg,
delete_done_label,
P4::None,
0,
);
// Seek to the rowid.
let seek_miss_label = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
table_cursor,
rowid_reg,
seek_miss_label,
P4::None,
0,
);
// RETURNING clause: read columns before deletion (row is still present).
if !stmt.returning.is_empty() {
let ret_count = result_column_count(&stmt.returning, table);
let ret_regs = b.alloc_regs(ret_count);
emit_column_reads(
b,
table_cursor,
&stmt.returning,
table,
stmt.table.alias.as_deref(),
schema,
ret_regs,
)?;
b.emit_op(Opcode::ResultRow, ret_regs, ret_count, 0, P4::None, 0);
}
// Index maintenance: delete from each index before deleting the row.
emit_index_deletes(b, table, table_cursor);
// Delete at cursor position.
// P5 bit 0 = OPFLAG_NCHANGE: count this deletion in changes().
b.emit_op(
Opcode::Delete,
table_cursor,
0,
0,
P4::Table(table.name.clone()),
1,
);
b.resolve_label(seek_miss_label);
// Loop back to read next rowid from the set.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let delete_loop_addr = delete_loop as i32;
b.emit_op(Opcode::Goto, 0, delete_loop_addr, 0, P4::None, 0);
b.resolve_label(delete_done_label);
// Close table cursor.
b.emit_op(Opcode::Close, table_cursor, 0, 0, P4::None, 0);
// Close index cursors (bd-34se).
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
for idx_offset in 0..table.indexes.len() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
// End label.
b.resolve_label(end_label);
Ok(())
}
// ---------------------------------------------------------------------------
// WITHOUT ROWID tables
// ---------------------------------------------------------------------------
//
// A WITHOUT ROWID table is physically an index b-tree (root page type 0x0A)
// keyed by its PRIMARY KEY. The full row record (in declared column order) is
// the b-tree key; the leading `pk_count` columns are compared as the primary
// key (the connection registers per-root-page index ordering metadata so the
// table cursor compares on the PK). There is no integer rowid, so the normal
// `NewRowid`/`Insert`/`SeekRowid`/`Delete` opcodes do not apply — DML is lowered
// to `IdxInsert`/`IdxDelete` against the table's own (index) b-tree cursor.
//
// The current storage model stores the record in declared column order, which
// matches the read paths (`cursor_column` and reload hydration), and compares a
// leading prefix as the key. This requires the PRIMARY KEY to be exactly the
// leading declared columns in declared order; other shapes are rejected with a
// clear "not yet supported" error rather than silently mis-ordering.
/// Table-column index for each plain-column key term of `index` (leftmost
/// first). Expression key terms — and any term whose name does not resolve to a
/// declared column — map to `None`, which never dedups against a PK column.
fn without_rowid_index_key_columns(table: &TableSchema, index: &IndexSchema) -> Vec<Option<usize>> {
(0..index.key_term_count())
.map(|p| {
index
.columns
.get(p)
.and_then(|name| table.column_index(name))
})
.collect()
}
/// SQLite WITHOUT ROWID rule (bd-5ava1 / GH #353): a secondary/auto index
/// stores the index key terms followed only by the primary-key columns that are
/// **not already** part of the index. Returns the subset of `pk_indices` (in
/// PRIMARY KEY order) to append to this index's on-disk key. A PK column that
/// coincides with an index key term is elided from the suffix, so an index like
/// `UNIQUE(pk_leading, x)` on `PRIMARY KEY(pk_leading, ...)` stores each PK
/// column exactly once — matching stock sqlite3's on-disk layout.
fn without_rowid_index_appended_pk(
table: &TableSchema,
index: &IndexSchema,
pk_indices: &[usize],
) -> Vec<usize> {
let key_cols = without_rowid_index_key_columns(table, index);
pk_indices
.iter()
.copied()
.filter(|pk| !key_cols.contains(&Some(*pk)))
.collect()
}
/// Index-record column position from which to read each primary-key column, in
/// PRIMARY KEY order, given the deduplicated WITHOUT ROWID key layout
/// (`[key terms..., appended PK...]`). A PK column that is also an index key
/// term is read from that leading key-term slot; the remaining PK columns are
/// read from the trailing appended region in PRIMARY KEY order.
fn without_rowid_index_pk_read_positions(
table: &TableSchema,
index: &IndexSchema,
pk_indices: &[usize],
) -> Vec<usize> {
let n_idx_key = index.key_term_count();
let key_cols = without_rowid_index_key_columns(table, index);
let mut next_trailing = n_idx_key;
pk_indices
.iter()
.map(|&pk_col| {
if let Some(p) = key_cols.iter().position(|kc| *kc == Some(pk_col)) {
p
} else {
let pos = next_trailing;
next_trailing += 1;
pos
}
})
.collect()
}
/// Position a WITHOUT ROWID table cursor on the row referenced by the current
/// entry of a secondary-index cursor (bd-rjaff).
///
/// WITHOUT ROWID secondary-index entries are `(key terms..., PK cols...)` —
/// there is no trailing rowid. This reads the PK suffix columns from the index
/// entry, packs them into a record, and probes the table b-tree with
/// `NoConflict` (prefix match over the leading PK columns). On a match the
/// table cursor is positioned on the row (fall-through); a missing row —
/// index/table inconsistency — jumps to `miss_label`.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_index_to_table_seek(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
idx_cursor: i32,
idx_schema: &IndexSchema,
pk_indices: &[usize],
miss_label: crate::Label,
) {
let n_pk = pk_indices.len();
// Deduplicated layout: a PK column that is also an index key term lives in
// its leading key-term slot, not the trailing suffix. Read each PK column
// from its true index-record position.
let read_positions = without_rowid_index_pk_read_positions(table, idx_schema, pk_indices);
let pk_regs = b.alloc_regs(n_pk as i32);
for (j, &pos) in read_positions.iter().enumerate() {
b.emit_op(
Opcode::Column,
idx_cursor,
pos as i32,
pk_regs + j as i32,
P4::None,
0,
);
}
let pk_rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_regs,
n_pk as i32,
pk_rec_reg,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
pk_rec_reg,
miss_label,
P4::None,
0,
);
}
/// Resolve the WITHOUT ROWID primary-key column indices, in PRIMARY KEY order.
///
/// Returns the declared table-column index of each PK column. Errors only if
/// the table has no PRIMARY KEY. Non-leading / reordered PRIMARY KEYs are
/// supported: the record is stored physically PK-leading (see `emit_wr_record`)
/// and the table cursor remaps reads back to declared order.
pub fn without_rowid_pk_indices(table: &TableSchema) -> Result<Vec<usize>, CodegenError> {
let pk_group = table.primary_key_constraints.first().ok_or_else(|| {
CodegenError::Unsupported(format!(
"WITHOUT ROWID table {} has no PRIMARY KEY",
table.name
))
})?;
if pk_group.is_empty() {
return Err(CodegenError::Unsupported(format!(
"WITHOUT ROWID table {} has an empty PRIMARY KEY",
table.name
)));
}
let mut indices = Vec::with_capacity(pk_group.len());
for name in pk_group {
let idx = table
.column_index(name)
.ok_or_else(|| CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.clone(),
})?;
indices.push(idx);
}
// Storage is physically PK-leading (emit_wr_record reorders on write, the
// table cursor remaps on read), so a non-leading / reordered PRIMARY KEY is
// fully representable — no ordering restriction here.
Ok(indices)
}
/// Emit the WITHOUT ROWID row record for `val_regs..val_regs+n_cols` (declared
/// order), physically reordered PK-leading — the PRIMARY KEY columns in PK
/// order, then the remaining columns in declared order — to match C SQLite's
/// on-disk WITHOUT ROWID record layout. Returns the record register.
///
/// For a leading-PK table the permutation is the identity, so this emits a
/// plain `MakeRecord` with no extra ops — byte-identical to the pre-reorder
/// path, keeping golden bytecode snapshots unchanged for the common shape.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_wr_record(
b: &mut ProgramBuilder,
pk_indices: &[usize],
val_regs: i32,
n_cols: usize,
aff_str: &str,
) -> i32 {
let rec_reg = b.alloc_reg();
if without_rowid_pk_is_leading(pk_indices, n_cols) {
b.emit_op(
Opcode::MakeRecord,
val_regs,
n_cols as i32,
rec_reg,
P4::Affinity(aff_str.to_owned()),
0,
);
return rec_reg;
}
// Non-leading PK: copy the declared-order value registers into a fresh
// contiguous block in physical (PK-leading) order, and reorder the
// per-column affinity string to match, before serializing.
let perm = without_rowid_storage_order(pk_indices, n_cols);
let phys_regs = b.alloc_regs(n_cols as i32);
let aff_bytes = aff_str.as_bytes();
let mut phys_aff = String::with_capacity(n_cols);
for (phys_slot, &decl_col) in perm.iter().enumerate() {
b.emit_op(
Opcode::Copy,
val_regs + decl_col as i32,
phys_regs + phys_slot as i32,
0,
P4::None,
0,
);
phys_aff.push(aff_bytes.get(decl_col).map_or('A', |&c| c as char));
}
b.emit_op(
Opcode::MakeRecord,
phys_regs,
n_cols as i32,
rec_reg,
P4::Affinity(phys_aff),
0,
);
rec_reg
}
/// Human-readable PK label for UNIQUE-violation error messages.
fn without_rowid_pk_label(table: &TableSchema, pk_indices: &[usize]) -> String {
// bd-a506j F1b: qualify each PK column with the table name so the constraint
// message reads "t.a, t.b" (stock), not "t.a, b". Callers pass this label
// directly (no extra "{table}." prefix).
pk_indices
.iter()
.filter_map(|&i| table.columns.get(i))
.map(|c| format!("{}.{}", table.name, c.name))
.collect::<Vec<_>>()
.join(", ")
}
/// Emit secondary-index inserts for a WITHOUT ROWID table row.
///
/// Index entries are keyed by `(index key terms..., primary-key columns...)`
/// — the PK columns take the place of the trailing rowid used by rowid tables.
/// Key terms and PK columns are read from `col_regs` (declared-order row image).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_index_inserts(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
col_regs: i32,
pk_indices: &[usize],
stmt_conflict: Option<ConflictAction>,
unique_conflicts_preflighted: bool,
) {
for (idx_offset, index) in table.indexes.iter().enumerate() {
// When the caller has already resolved every UNIQUE victim in clustered
// terms, hand the engine ABORT: its OE_REPLACE branch resolves victims
// by rowid and cannot address a WITHOUT ROWID clustered row.
let oe_flag = if unique_conflicts_preflighted && index.is_unique {
OE_ABORT
} else {
effective_oe(stmt_conflict, index.conflict_action)
};
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
// GH #353: append only the PK columns not already covered by this
// index's key terms (stock sqlite3's WITHOUT ROWID on-disk layout).
let appended_pk = without_rowid_index_appended_pk(table, index, pk_indices);
let n_pk = appended_pk.len();
let skip_label = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(col_regs),
secondaries: &[],
};
emit_index_predicate_guard(b, index, &scan_ctx, skip_label);
let idx_key_regs = b.alloc_regs((n_idx_cols + n_pk) as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(b, index, key_pos, idx_key_regs + key_pos as i32, &scan_ctx);
}
for (j, &pk_col) in appended_pk.iter().enumerate() {
b.emit_op(
Opcode::Copy,
col_regs + pk_col as i32,
idx_key_regs + (n_idx_cols + j) as i32,
0,
P4::None,
0,
);
}
let idx_rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
idx_key_regs,
(n_idx_cols + n_pk) as i32,
idx_rec_reg,
P4::None,
0,
);
let (p3_unique, p5_unique) = if index.is_unique {
(n_idx_cols as i32, 1u16 | (oe_flag << 1))
} else {
(0, 0u16)
};
let p4_name = if index.is_unique {
P4::Table(index.key_label_qualified(&table.name))
} else {
P4::Table(index.name.clone())
};
b.emit_op(
Opcode::IdxInsert,
idx_cursor,
idx_rec_reg,
p3_unique,
p4_name,
p5_unique,
);
b.resolve_label(skip_label);
}
}
/// Emit secondary-index deletes for a WITHOUT ROWID table row.
///
/// When `col_regs` is `Some`, key terms and PK columns are read from the
/// register image; when `None`, they are read from the row at the current
/// `table_cursor` position (via `Opcode::Column`).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_index_deletes(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
col_regs: Option<i32>,
pk_indices: &[usize],
) {
for (idx_offset, index) in table.indexes.iter().enumerate() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
// GH #353: the delete key must match the deduplicated insert layout —
// only PK columns not already in this index's key terms are appended.
let appended_pk = without_rowid_index_appended_pk(table, index, pk_indices);
let n_pk = appended_pk.len();
let skip_label = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: col_regs,
secondaries: &[],
};
emit_index_predicate_guard(b, index, &scan_ctx, skip_label);
let idx_key_regs = b.alloc_regs((n_idx_cols + n_pk) as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(b, index, key_pos, idx_key_regs + key_pos as i32, &scan_ctx);
}
for (j, &pk_col) in appended_pk.iter().enumerate() {
let dst = idx_key_regs + (n_idx_cols + j) as i32;
if let Some(cr) = col_regs {
b.emit_op(Opcode::Copy, cr + pk_col as i32, dst, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
table_cursor,
pk_col as i32,
dst,
P4::None,
0,
);
}
}
b.emit_op(
Opcode::IdxDelete,
idx_cursor,
idx_key_regs,
(n_idx_cols + n_pk) as i32,
P4::Table(index.name.clone()),
0,
);
b.resolve_label(skip_label);
}
}
/// Rewrite one located WITHOUT ROWID row from its OLD image to its NEW image.
///
/// bd-yuj70: the previous emission deleted the OLD secondary-index entries and
/// the OLD table row *before* the NEW primary key was probed, so a NEW-PK
/// collision with a **different** row reached `IdxInsert` with `OE_REPLACE`
/// and bypassed the explicit victim-secondary cleanup that
/// [`emit_without_rowid_row_insert`] performs. That left the victim's
/// secondary-index entries pointing at a deleted clustered record, and the
/// ABORT/FAIL/ROLLBACK paths had already destroyed OLD before raising.
///
/// The mutation order here is fixed:
/// 1. the caller computes, validates, and affinitizes NEW before any mutation;
/// 2. probe the NEW primary key and distinguish a *self* rewrite (NEW PK equals
/// OLD PK) from an *other* victim;
/// 3. resolve the conflict action against that other victim first — REPLACE
/// removes the victim's secondary entries and its clustered row, IGNORE
/// abandons the row without mutating anything, and the ABORT family raises
/// the UNIQUE violation while OLD is still intact;
/// 4. only then delete the OLD secondary entries and the OLD clustered row;
/// 5. insert NEW under `OE_ABORT` so the write cannot recursively REPLACE.
///
/// `new_regs` holds the NEW image (declared column order, already affinitized),
/// `old_regs` the OLD image used for the OLD secondary-index keys, and
/// `old_pk_regs` the OLD primary-key values used to re-seek the OLD row after
/// the probes have moved the cursor.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn emit_without_rowid_update_rewrite(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
new_regs: i32,
old_regs: i32,
old_pk_regs: i32,
pk_indices: &[usize],
oe_flag: u16,
stmt_level: Option<ConflictAction>,
row_done: Label,
) {
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
let pk_label = without_rowid_pk_label(table, pk_indices);
let aff_str = table.affinity_string();
// PHASE A — decide every conflict before mutating anything.
//
// Ordering follows `emit_without_rowid_row_insert` (and SQLite's
// `sqlite3GenerateConstraintChecks`): the primary key is resolved first,
// then each index in `table.indexes` schema order. Nothing in phase A
// deletes: a REPLACE decision only *captures* the victim's primary key, so
// a later IGNORE or ABORT on a different index still leaves the database
// exactly as it was. Phase B then applies the captured deletions.
let pk_victim_flag = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, pk_victim_flag, 0, P4::None, 0);
let unique_index_slots: Vec<(usize, i32, i32)> = table
.indexes
.iter()
.enumerate()
.filter(|(_, index)| index.is_unique && index.key_term_count() > 0)
.map(|(idx_offset, _)| {
let flag = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, flag, 0, P4::None, 0);
let victim_pk = b.alloc_regs(n_pk as i32);
(idx_offset, flag, victim_pk)
})
.collect();
// A1: is the primary key unchanged? Compare collation-aware, per key
// column: a single differing column makes this an "other victim" probe.
let pk_changed = b.emit_label();
let pk_unchanged = b.emit_label();
for (j, &pk_col) in pk_indices.iter().enumerate() {
let pk_collation = table.columns[pk_col]
.collation
.as_deref()
.filter(|name| !name.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |name| P4::Collation(name.to_owned()));
b.emit_jump_to_label(
Opcode::Ne,
new_regs + pk_col as i32,
old_pk_regs + j as i32,
pk_changed,
pk_collation,
0,
);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, pk_unchanged, P4::None, 0);
// A2: the primary key moved — decide against any *other* row holding NEW PK.
b.resolve_label(pk_changed);
let new_pk_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
new_regs + pk_col as i32,
new_pk_regs + j as i32,
0,
P4::None,
0,
);
}
let new_pk_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
new_pk_regs,
n_pk as i32,
new_pk_rec,
P4::None,
0,
);
let no_victim = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
new_pk_rec,
no_victim,
P4::None,
0,
);
if oe_flag == OE_REPLACE {
// Record the decision only. The victim's primary key *is* the NEW
// primary key, so no separate capture registers are needed; phase B
// re-seeks it. Deleting here would be visible to a later IGNORE.
b.emit_op(Opcode::Integer, 1, pk_victim_flag, 0, P4::None, 0);
} else if oe_flag == OE_IGNORE {
// Abandon the row with OLD still intact.
b.emit_jump_to_label(Opcode::Goto, 0, 0, row_done, P4::None, 0);
} else {
// ABORT / FAIL / ROLLBACK: raise the UNIQUE violation now, while OLD is
// still present, so the statement cannot lose the OLD row on the way
// out. `NoConflict` fell through, so a conflicting row provably exists
// and this insert cannot silently succeed.
let abort_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
new_regs,
n_cols as i32,
abort_rec,
P4::Affinity(aff_str.clone()),
0,
);
b.emit_op(
Opcode::IdxInsert,
table_cursor,
abort_rec,
n_pk as i32,
P4::Table(pk_label.clone()),
1u16 | (oe_flag << 1),
);
}
b.resolve_label(no_victim);
b.resolve_label(pk_unchanged);
// A3: decide every UNIQUE secondary-index conflict, still without mutating.
//
// The engine's `IdxInsert` OE_REPLACE branch resolves its victim as a
// *rowid* (`find_conflicting_rowid_in_index` -> `Option<i64>`, then
// `native_replace_row`, which seeks the table B-tree by rowid). A WITHOUT
// ROWID secondary entry ends with the PRIMARY KEY, not an integer rowid, so
// that path cannot address the victim and instead reports the healthy
// database as malformed. Every unique conflict is therefore resolved here,
// in clustered terms, and phase C hands the engine a non-REPLACE action so
// the rowid-suffix path is never entered.
for &(idx_offset, victim_flag, victim_pk_regs) in &unique_index_slots {
let index = &table.indexes[idx_offset];
let idx_oe = effective_oe(stmt_level, index.conflict_action);
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
// GH #353: deduplicated WITHOUT ROWID key layout — the victim PK is read
// from these per-column positions, and the raise key appends only the
// non-overlapping PK suffix.
let read_positions = without_rowid_index_pk_read_positions(table, index, pk_indices);
let appended_pk = without_rowid_index_appended_pk(table, index, pk_indices);
let idx_done = b.emit_label();
// A partial index only constrains rows its predicate admits: when NEW
// is outside the predicate it cannot conflict on this index at all.
let new_scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(new_regs),
secondaries: &[],
};
emit_index_predicate_guard(b, index, &new_scan_ctx, idx_done);
// Probe the unique prefix only (the PK suffix is what distinguishes
// rows, so it must not participate in the conflict test).
let probe_regs = b.alloc_regs(n_idx_cols as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(
b,
index,
key_pos,
probe_regs + key_pos as i32,
&new_scan_ctx,
);
}
// SQL UNIQUE never constrains NULL keys: a NULL in any key term means
// this row cannot collide, matching `IdxInsert`'s own NoConflict
// semantics.
for key_pos in 0..n_idx_cols {
b.emit_jump_to_label(
Opcode::IsNull,
probe_regs + key_pos as i32,
0,
idx_done,
P4::None,
0,
);
}
let probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_regs,
n_idx_cols as i32,
probe_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
probe_rec,
idx_done,
P4::None,
0,
);
// A conflicting entry exists and `idx_cursor` is positioned on it. Its
// key terms plus non-overlapping suffix reconstruct the victim's
// PRIMARY KEY — read each PK column from its true index-record slot.
for (j, &pos) in read_positions.iter().enumerate() {
b.emit_op(
Opcode::Column,
idx_cursor,
pos as i32,
victim_pk_regs + j as i32,
P4::None,
0,
);
}
// Self-conflict: the entry belongs to the row being rewritten, so the
// OLD delete in phase B already removes it. Comparing against OLD PK
// (not NEW) is what makes a pure secondary-key rewrite a no-op here.
let victim_is_other = b.emit_label();
for (j, &pk_col) in pk_indices.iter().enumerate() {
let pk_collation = table.columns[pk_col]
.collation
.as_deref()
.filter(|name| !name.eq_ignore_ascii_case("BINARY"))
.map_or(P4::None, |name| P4::Collation(name.to_owned()));
b.emit_jump_to_label(
Opcode::Ne,
victim_pk_regs + j as i32,
old_pk_regs + j as i32,
victim_is_other,
pk_collation,
0,
);
}
b.emit_jump_to_label(Opcode::Goto, 0, 0, idx_done, P4::None, 0);
b.resolve_label(victim_is_other);
if idx_oe == OE_IGNORE {
// Abandon the whole row with OLD intact.
b.emit_jump_to_label(Opcode::Goto, 0, 0, row_done, P4::None, 0);
} else if idx_oe == OE_REPLACE {
// Record the decision only: `victim_pk_regs` already holds the
// victim's primary key. Phase B re-seeks and deletes it, so a
// later IGNORE or ABORT on another index still sees an unmutated
// database.
b.emit_op(Opcode::Integer, 1, victim_flag, 0, P4::None, 0);
} else {
// ABORT / FAIL / ROLLBACK: raise the UNIQUE violation now, while
// OLD and every captured victim are still present. The full
// [key || pk] record is what the engine's unique check expects;
// `NoConflict` fell through, so a conflicting entry provably exists
// and this cannot silently succeed.
let n_pk_app = appended_pk.len();
let raise_regs = b.alloc_regs((n_idx_cols + n_pk_app) as i32);
for key_pos in 0..n_idx_cols {
b.emit_op(
Opcode::Copy,
probe_regs + key_pos as i32,
raise_regs + key_pos as i32,
0,
P4::None,
0,
);
}
for (j, &pk_col) in appended_pk.iter().enumerate() {
b.emit_op(
Opcode::Copy,
new_regs + pk_col as i32,
raise_regs + (n_idx_cols + j) as i32,
0,
P4::None,
0,
);
}
let raise_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
raise_regs,
(n_idx_cols + n_pk_app) as i32,
raise_rec,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
idx_cursor,
raise_rec,
n_idx_cols as i32,
P4::Table(index.key_label_qualified(&table.name)),
1u16 | (idx_oe << 1),
);
}
b.resolve_label(idx_done);
}
// PHASE B — apply the captured REPLACE deletions.
//
// Every decision is now final: no IGNORE or ABORT can still fire, so these
// deletions cannot become an unintended partial mutation. Each victim is
// re-seeked by primary key and skipped when already absent, which is what
// makes duplicate victims safe — the same row reached through two indexes,
// or a secondary victim that is also the primary-key victim, is deleted
// once and the later seeks find nothing.
let emit_captured_victim_delete = |b: &mut ProgramBuilder, flag: i32, pk_regs: i32| {
let victim_done = b.emit_label();
b.emit_jump_to_label(Opcode::IfNot, flag, 1, victim_done, P4::None, 0);
let victim_seek_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_regs,
n_pk as i32,
victim_seek_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
victim_seek_rec,
victim_done,
P4::None,
0,
);
emit_without_rowid_index_deletes(b, table, table_cursor, None, pk_indices);
b.emit_op(
Opcode::IdxDelete,
table_cursor,
0,
0,
P4::Table(table.name.clone()),
OPFLAG_REPLACE_VICTIM,
);
b.resolve_label(victim_done);
};
// B1: the primary-key victim, whose primary key is the NEW primary key.
emit_captured_victim_delete(b, pk_victim_flag, new_pk_regs);
// B2: each captured secondary victim, in the same index order as phase A.
for &(_, victim_flag, victim_pk_regs) in &unique_index_slots {
emit_captured_victim_delete(b, victim_flag, victim_pk_regs);
}
// B3: re-seek OLD (the probes above moved the cursor) and remove its
// secondary-index entries and clustered row.
let old_seek_regs = b.alloc_regs(n_pk as i32);
for j in 0..n_pk {
b.emit_op(
Opcode::Copy,
old_pk_regs + j as i32,
old_seek_regs + j as i32,
0,
P4::None,
0,
);
}
let old_seek_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
old_seek_regs,
n_pk as i32,
old_seek_rec,
P4::None,
0,
);
let old_absent = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
old_seek_rec,
old_absent,
P4::None,
0,
);
emit_without_rowid_index_deletes(b, table, table_cursor, Some(old_regs), pk_indices);
b.emit_op(Opcode::IdxDelete, table_cursor, 0, 0, P4::None, 0);
b.resolve_label(old_absent);
// PHASE C: insert NEW. Every conflicting row has been resolved above, so
// this insert must not itself REPLACE.
let rec_reg = emit_wr_record(b, pk_indices, new_regs, n_cols, &aff_str);
b.emit_op(
Opcode::IdxInsert,
table_cursor,
rec_reg,
n_pk as i32,
P4::Table(pk_label.clone()),
1u16 | (OE_ABORT << 1) | OPFLAG_IDX_NCHANGE,
);
emit_without_rowid_index_inserts(
b,
table,
table_cursor,
new_regs,
pk_indices,
stmt_level,
true,
);
}
/// Emit the per-row insert for a WITHOUT ROWID table.
///
/// `val_regs` holds the full row image in declared column order. Handles
/// constraint validation, conflict resolution (ABORT/IGNORE/REPLACE), the table
/// b-tree `IdxInsert`, and secondary-index maintenance.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
fn emit_without_rowid_row_insert(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
val_regs: i32,
pk_indices: &[usize],
oe_flag: u16,
stmt_level: Option<ConflictAction>,
upserts: &[UpsertClause],
target_alias: Option<&str>,
returning: &[ResultColumn],
schema: &[TableSchema],
) -> Result<(), CodegenError> {
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
// Candidate-row validation runs for EVERY row BEFORE conflict routing
// (mirrors the rowid path): STORED generated columns, STRICT type check,
// affinity coercion (GH #169 — so CHECK/NOT NULL see the coerced value and
// the conflict probe keys match stored format), then CHECK / NOT NULL. Stock
// enforces these on the candidate even for a row that will conflict and
// DO UPDATE, so they must precede the ON CONFLICT chain (bd-xa2qv). Under a
// statement-level IGNORE the candidate is abandoned via `candidate_skip`.
emit_stored_generated_columns(b, table, val_regs);
emit_strict_type_check(b, table, val_regs);
b.emit_op(
Opcode::Affinity,
val_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
let candidate_skip = if oe_flag == OE_IGNORE {
Some(b.emit_label())
} else {
None
};
emit_check_constraints(b, table, val_regs, candidate_skip);
emit_not_null_constraints(b, table, val_regs, stmt_level, candidate_skip);
// UPSERT: a chain of ON CONFLICT clauses (SQLite 3.35+). Each clause probes
// its own conflict target in written order; the first clause whose target is
// violated is applied (DO UPDATE rewrites the specific conflicting row; DO
// NOTHING skips the insert). A conflict on a constraint that no clause
// targets falls through to the plain-insert body below, which enforces the
// remaining constraints under `oe_flag`/`stmt_level` (default ABORT) —
// matching stock (bd-xa2qv, mirrors the rowid path bd-aap9u). The plain
// insert is shared as the chain's fall-through, so `done_label` is resolved
// after it.
let upsert_done_label = if upserts.is_empty() {
None
} else {
let done_label = b.emit_label();
let insert_label = b.emit_label();
for (clause_idx, clause) in upserts.iter().enumerate() {
// No conflict on this clause's target routes to the next clause; the
// last clause routes to the plain-insert fall-through.
let no_conflict_label = if clause_idx + 1 == upserts.len() {
insert_label
} else {
b.emit_label()
};
let conflict_label = b.emit_label();
// Conflict-target routing:
// - omitted target -> DO UPDATE fires on ANY uniqueness constraint;
// probe the PRIMARY KEY and every UNIQUE secondary index.
// - explicit PRIMARY KEY -> PK-only (find_upsert_target_index
// returns None for the PK, which is the table btree, not in
// `table.indexes`).
// - explicit secondary UNIQUE (bd-yqjjx) -> probe just that index;
// a PK/other-index collision routes to `no_conflict_label`.
let explicit_target_index =
find_upsert_target_index(table, clause.target.as_ref()).map(|(offset, _)| offset);
let probe_unique_secondaries = clause.target.is_none();
emit_without_rowid_upsert_probe(
b,
table,
table_cursor,
val_regs,
pk_indices,
probe_unique_secondaries,
explicit_target_index,
conflict_label,
no_conflict_label,
);
b.resolve_label(conflict_label);
match &clause.action {
UpsertAction::Nothing => {
// Conflict on this target -> skip the insert entirely.
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
}
UpsertAction::Update {
assignments,
where_clause,
} => {
emit_without_rowid_upsert_do_update_apply(
b,
table,
table_cursor,
val_regs,
pk_indices,
stmt_level,
assignments,
where_clause.as_deref(),
target_alias,
returning,
schema,
)?;
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
}
}
if clause_idx + 1 != upserts.len() {
b.resolve_label(no_conflict_label);
}
}
// Fall-through: no clause's target conflicted. The plain-insert body
// below runs under `oe_flag`/`stmt_level`.
b.resolve_label(insert_label);
Some(done_label)
};
// Plain insert of the candidate row. Candidate validation (STORED generated
// columns, STRICT type check, affinity, CHECK, NOT NULL) already ran above,
// before conflict routing. Apply column affinities before packing the record
// (matches stored format).
let aff_str = table.affinity_string();
b.emit_op(
Opcode::Affinity,
val_regs,
n_cols as i32,
0,
P4::Affinity(aff_str.clone()),
0,
);
let row_done = b.emit_label();
let pk_label = without_rowid_pk_label(table, pk_indices);
// Decide every UNIQUE action before mutating the clustered table. A
// WITHOUT ROWID secondary entry ends in the composite PK, not a rowid, so
// the engine's rowid-table REPLACE path cannot identify its victim.
let pk_probe_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
val_regs + pk_col as i32,
pk_probe_regs + j as i32,
0,
P4::None,
0,
);
}
let pk_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_probe_regs,
n_pk as i32,
pk_probe_rec,
P4::None,
0,
);
let pk_victim_flag = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, pk_victim_flag, 0, P4::None, 0);
let pk_clear = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
pk_probe_rec,
pk_clear,
P4::None,
0,
);
if oe_flag == OE_IGNORE {
b.emit_jump_to_label(Opcode::Goto, 0, 0, row_done, P4::None, 0);
} else if oe_flag == OE_REPLACE {
b.emit_op(Opcode::Integer, 1, pk_victim_flag, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Halt,
ErrorCode::Constraint as i32,
0,
0,
P4::Str(pk_label.clone()),
OPFLAG_HALT_UNIQUE,
);
}
b.resolve_label(pk_clear);
let unique_index_slots: Vec<(usize, i32, i32)> = table
.indexes
.iter()
.enumerate()
.filter(|(_, index)| index.is_unique && index.key_term_count() > 0)
.map(|(idx_offset, _)| {
let flag = b.alloc_reg();
b.emit_op(Opcode::Integer, 0, flag, 0, P4::None, 0);
let victim_pk = b.alloc_regs(n_pk as i32);
(idx_offset, flag, victim_pk)
})
.collect();
for &(idx_offset, victim_flag, victim_pk_regs) in &unique_index_slots {
let index = &table.indexes[idx_offset];
let idx_oe = effective_oe(stmt_level, index.conflict_action);
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
// GH #353: reconstruct the victim PK from the deduplicated key layout.
let read_positions = without_rowid_index_pk_read_positions(table, index, pk_indices);
let idx_clear = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
emit_index_predicate_guard(b, index, &scan_ctx, idx_clear);
let probe_regs = b.alloc_regs(n_idx_cols as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(b, index, key_pos, probe_regs + key_pos as i32, &scan_ctx);
b.emit_jump_to_label(
Opcode::IsNull,
probe_regs + key_pos as i32,
0,
idx_clear,
P4::None,
0,
);
}
let probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
probe_regs,
n_idx_cols as i32,
probe_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
probe_rec,
idx_clear,
P4::None,
0,
);
for (j, &pos) in read_positions.iter().enumerate() {
b.emit_op(
Opcode::Column,
idx_cursor,
pos as i32,
victim_pk_regs + j as i32,
P4::None,
0,
);
}
if idx_oe == OE_IGNORE {
b.emit_jump_to_label(Opcode::Goto, 0, 0, row_done, P4::None, 0);
} else if idx_oe == OE_REPLACE {
b.emit_op(Opcode::Integer, 1, victim_flag, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Halt,
ErrorCode::Constraint as i32,
0,
0,
P4::Str(index.key_label_qualified(&table.name)),
OPFLAG_HALT_UNIQUE,
);
}
b.resolve_label(idx_clear);
}
let emit_victim_delete = |b: &mut ProgramBuilder, flag: i32, victim_pk_regs: i32| {
let victim_done = b.emit_label();
b.emit_jump_to_label(Opcode::IfNot, flag, 1, victim_done, P4::None, 0);
let victim_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
victim_pk_regs,
n_pk as i32,
victim_rec,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
victim_rec,
victim_done,
P4::None,
0,
);
emit_without_rowid_index_deletes(b, table, table_cursor, None, pk_indices);
b.emit_op(
Opcode::IdxDelete,
table_cursor,
0,
0,
P4::Table(table.name.clone()),
OPFLAG_REPLACE_VICTIM,
);
b.resolve_label(victim_done);
};
emit_victim_delete(b, pk_victim_flag, pk_probe_regs);
for &(_, victim_flag, victim_pk_regs) in &unique_index_slots {
emit_victim_delete(b, victim_flag, victim_pk_regs);
}
let rec_reg = emit_wr_record(b, pk_indices, val_regs, n_cols, &aff_str);
b.emit_op(
Opcode::IdxInsert,
table_cursor,
rec_reg,
n_pk as i32,
P4::Table(pk_label.clone()),
1u16 | (OE_ABORT << 1) | OPFLAG_IDX_NCHANGE,
);
// Secondary-index maintenance (skipped by the IGNORE conflict path, which
// jumps straight to `row_done`).
emit_without_rowid_index_inserts(
b,
table,
table_cursor,
val_regs,
pk_indices,
stmt_level,
true,
);
// RETURNING: emit the inserted row image. Placed on the insert path so an
// IGNORE conflict (which jumps to `row_done`) produces no RETURNING row,
// matching SQLite. `val_regs` holds the post-affinity stored values.
if !returning.is_empty() {
emit_returning_from_regs(b, table, returning, target_alias, schema, val_regs)?;
}
b.resolve_label(row_done);
// The DO UPDATE / DO NOTHING chain arms jump here, skipping the plain insert.
if let Some(done_label) = upsert_done_label {
b.resolve_label(done_label);
}
// A candidate CHECK/NOT NULL violation under a statement-level IGNORE
// abandons the whole row here (skipping conflict routing and the insert).
if let Some(label) = candidate_skip {
b.resolve_label(label);
}
Ok(())
}
/// Emit the conflict probe for one UPSERT `ON CONFLICT` clause on a WITHOUT
/// ROWID table. On a uniqueness conflict against the clause's target the table
/// cursor is left positioned on the conflicting row and control jumps to
/// `conflict_label`; on no conflict (or an index/table inconsistency) control
/// jumps to `no_conflict_label`.
///
/// Routing mirrors the rowid `emit_upsert_probe`:
/// - `explicit_target_index = Some(i)` (an explicit secondary-UNIQUE target,
/// bd-yqjjx): probe ONLY that index. A PRIMARY KEY or other-index collision
/// is not the named arbiter, so it routes to `no_conflict_label`.
/// - omitted target (`probe_unique_secondaries = true`): probe the PRIMARY KEY,
/// then each UNIQUE secondary index in index order; the first hit wins.
/// - explicit PRIMARY KEY target (`explicit_target_index = None`,
/// `probe_unique_secondaries = false`): probe the PRIMARY KEY only.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[allow(clippy::too_many_arguments)]
fn emit_without_rowid_upsert_probe(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
val_regs: i32,
pk_indices: &[usize],
probe_unique_secondaries: bool,
explicit_target_index: Option<usize>,
conflict_label: Label,
no_conflict_label: Label,
) {
let n_pk = pk_indices.len();
if let Some(target_idx) = explicit_target_index {
// Explicit secondary-UNIQUE conflict target (bd-yqjjx): DO UPDATE fires
// ONLY on a conflict against THIS index. A PRIMARY KEY collision, or a
// collision on any OTHER unique index, is not this target — it routes to
// `no_conflict_label`. So there is deliberately no PK-for-update probe
// here; probe only the target index, exactly like a single iteration of
// the omitted-target secondary loop below.
let index = &table.indexes[target_idx];
let idx_cursor = table_cursor + 1 + target_idx as i32;
let n_idx_cols = index.key_term_count();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
let idx_key_regs = b.alloc_regs(n_idx_cols as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(b, index, key_pos, idx_key_regs + key_pos as i32, &scan_ctx);
}
let idx_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
idx_key_regs,
n_idx_cols as i32,
idx_probe_rec,
P4::None,
0,
);
// No existing entry shares these key terms (a NULL key term never
// conflicts — SQLite UNIQUE semantics) -> no conflict for this clause.
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
idx_probe_rec,
no_conflict_label,
P4::None,
0,
);
// Conflict: position the table cursor on the conflicting row via the
// index entry's PK suffix. If the index and table are inconsistent (row
// missing), route to `no_conflict_label`.
emit_without_rowid_index_to_table_seek(
b,
table,
table_cursor,
idx_cursor,
index,
pk_indices,
no_conflict_label,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, conflict_label, P4::None, 0);
} else {
// Probe the PRIMARY KEY: build a PK-prefix key and test for an existing row.
let pk_probe_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
val_regs + pk_col as i32,
pk_probe_regs + j as i32,
0,
P4::None,
0,
);
}
let pk_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_probe_regs,
n_pk as i32,
pk_probe_rec,
P4::None,
0,
);
// NoConflict jumps when there is NO existing PK match; a match falls through
// with the table cursor positioned on the conflicting row.
let pk_no_conflict = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
pk_probe_rec,
pk_no_conflict,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, conflict_label, P4::None, 0);
b.resolve_label(pk_no_conflict);
// Omitted conflict target: SQLite fires DO UPDATE on whichever uniqueness
// constraint is violated, checked in constraint order — the PRIMARY KEY
// (above), then each UNIQUE secondary index in index order. Probe each
// UNIQUE index against the attempted-insert values; on the first hit,
// position the table cursor on that row via its PK suffix and route to
// `conflict_label`. An explicit PRIMARY KEY target skips this (PK-only).
if probe_unique_secondaries {
for (idx_offset, index) in table.indexes.iter().enumerate() {
if !index.is_unique {
continue;
}
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
let next_index = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
let idx_key_regs = b.alloc_regs(n_idx_cols as i32);
for key_pos in 0..n_idx_cols {
emit_index_key_term(
b,
index,
key_pos,
idx_key_regs + key_pos as i32,
&scan_ctx,
);
}
let idx_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
idx_key_regs,
n_idx_cols as i32,
idx_probe_rec,
P4::None,
0,
);
// NoConflict jumps to `next_index` when no existing entry shares
// these key terms (a NULL key term never conflicts — SQLite UNIQUE
// semantics). On a conflict the index cursor is positioned on the
// conflicting entry.
b.emit_jump_to_label(
Opcode::NoConflict,
idx_cursor,
idx_probe_rec,
next_index,
P4::None,
0,
);
// Position the table cursor on the conflicting row via the index
// entry's PK suffix, then route to `conflict_label`. If the index
// and table are inconsistent (row missing), route to
// `no_conflict_label`.
emit_without_rowid_index_to_table_seek(
b,
table,
table_cursor,
idx_cursor,
index,
pk_indices,
no_conflict_label,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, conflict_label, P4::None, 0);
b.resolve_label(next_index);
}
}
// No uniqueness conflict against this clause's target(s).
b.emit_jump_to_label(Opcode::Goto, 0, 0, no_conflict_label, P4::None, 0);
}
}
/// Emit the DO UPDATE apply body for one UPSERT clause on a WITHOUT ROWID table.
/// The table cursor must already be positioned on the conflicting row (by
/// [`emit_without_rowid_upsert_probe`]). Reads the existing row, evaluates the
/// optional `WHERE` (on false/NULL skips the whole update), applies the SET
/// assignments (with `excluded.*` bound to the attempted-insert registers),
/// re-validates constraints, removes the OLD row + index entries, re-inserts the
/// rewritten image, and emits RETURNING. Does not emit the trailing jump to the
/// chain's done label — the caller does.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[allow(clippy::too_many_arguments)]
fn emit_without_rowid_upsert_do_update_apply(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
val_regs: i32,
pk_indices: &[usize],
stmt_level: Option<ConflictAction>,
assignments: &[fsqlite_ast::Assignment],
where_clause: Option<&Expr>,
target_alias: Option<&str>,
returning: &[ResultColumn],
schema: &[TableSchema],
) -> Result<(), CodegenError> {
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
let existing_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
table_cursor,
i as i32,
existing_regs + i as i32,
P4::None,
0,
);
}
// `excluded.*` resolves to the attempted-insert values (val_regs); unqualified
// column refs resolve to the existing row (existing_regs).
//
// bd-xjfrt: as in the rowid path, `existing_ctx` carries the real `schema` and
// a register-backed `excluded.*` secondary so correlated subqueries/EXISTS in
// a DO UPDATE SET expression resolve against the target row and val_regs
// instead of collapsing to NULL.
let excluded_secondary = [SecondaryScan {
cursor: table_cursor,
table,
table_alias: Some("excluded"),
register_base: Some(val_regs),
}];
let excluded_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: Some("excluded"),
schema: Some(schema),
register_base: Some(val_regs),
secondaries: &[],
};
let existing_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: target_alias,
schema: Some(schema),
register_base: Some(existing_regs),
secondaries: &excluded_secondary,
};
// WITHOUT ROWID tables have no hidden rowid, so no column ever resolves to a
// hidden rowid; the excluded-side helper still needs a register value, so
// pass a scratch register that is never read.
let excluded_hidden_rowid_reg = b.alloc_reg();
// Optional `DO UPDATE SET ... WHERE <cond>`: when false/NULL, skip the whole
// update (and its RETURNING row), leaving the existing row unchanged. A
// skipped update still counts the conflict as handled — the caller jumps to
// the chain's done label, so the row is neither updated nor inserted.
let skip_update_label = if let Some(where_expr) = where_clause {
let label = b.emit_label();
let where_reg = b.alloc_reg();
emit_upsert_expr(
b,
where_expr,
where_reg,
&existing_ctx,
&excluded_ctx,
table,
None,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(Opcode::IfNot, where_reg, 1, label, P4::None, 0);
Some(label)
} else {
None
};
// Apply the assignments into the existing-row image, recompute generated
// columns, and validate constraints on the new image.
emit_upsert_assignments(
b,
assignments,
table,
existing_regs,
&existing_ctx,
&excluded_ctx,
None,
excluded_hidden_rowid_reg,
)?;
emit_stored_generated_columns(b, table, existing_regs);
emit_strict_type_check(b, table, existing_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
existing_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_check_constraints(b, table, existing_regs, None);
emit_not_null_constraints(b, table, existing_regs, stmt_level, None);
// Remove the OLD secondary-index entries (read from the cursor's old row)
// and the OLD table row, then insert the rewritten row + new index entries.
emit_without_rowid_index_deletes(b, table, table_cursor, None, pk_indices);
b.emit_op(Opcode::IdxDelete, table_cursor, 0, 0, P4::None, 0);
let aff_str = table.affinity_string();
b.emit_op(
Opcode::Affinity,
existing_regs,
n_cols as i32,
0,
P4::Affinity(aff_str.clone()),
0,
);
let rec_reg = emit_wr_record(b, pk_indices, existing_regs, n_cols, &aff_str);
b.emit_op(
Opcode::IdxInsert,
table_cursor,
rec_reg,
n_pk as i32,
P4::Table(without_rowid_pk_label(table, pk_indices)),
1u16 | (OE_ABORT << 1) | OPFLAG_IDX_NCHANGE,
);
emit_without_rowid_index_inserts(
b,
table,
table_cursor,
existing_regs,
pk_indices,
stmt_level,
false,
);
if !returning.is_empty() {
emit_returning_from_regs(b, table, returning, target_alias, schema, existing_regs)?;
}
// A false `WHERE` skips straight here (no update, no RETURNING); the caller
// then jumps to the chain's done label.
if let Some(label) = skip_update_label {
b.resolve_label(label);
}
Ok(())
}
/// Open the table (index) cursor plus all secondary-index cursors for write.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_open_write(b: &mut ProgramBuilder, table: &TableSchema, table_cursor: i32) {
b.emit_op(
Opcode::OpenWrite,
table_cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
for (idx_offset, index) in table.indexes.iter().enumerate() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(
Opcode::OpenWrite,
idx_cursor,
index.root_page,
0,
P4::Table(index.name.clone()),
0,
);
}
}
/// Close the table cursor and all secondary-index cursors.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_close(b: &mut ProgramBuilder, table: &TableSchema, table_cursor: i32) {
b.emit_op(Opcode::Close, table_cursor, 0, 0, P4::None, 0);
for idx_offset in 0..table.indexes.len() {
let idx_cursor = table_cursor + 1 + idx_offset as i32;
b.emit_op(Opcode::Close, idx_cursor, 0, 0, P4::None, 0);
}
}
/// Code generation for INSERT into a WITHOUT ROWID table.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn codegen_insert_without_rowid(
b: &mut ProgramBuilder,
stmt: &InsertStatement,
table: &TableSchema,
schema: &[TableSchema],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
let pk_indices = without_rowid_pk_indices(table)?;
let table_cursor = 0_i32;
let n_cols = table.columns.len();
let target_alias = stmt.alias.as_deref();
// Conflict behavior. A statement-level `INSERT OR <algo>` (or the default
// ABORT) governs BOTH the pre-insert NOT NULL/CHECK checks on the candidate
// row and the fall-through insert's remaining (non-target) UNIQUE/PK
// constraints. The per-constraint `ON CONFLICT` chain is routed row-by-row in
// `emit_without_rowid_row_insert`: each clause probes its own target in
// written order and the first whose target conflicts is applied (DO UPDATE
// rewrites the conflicting row; DO NOTHING skips the insert). A conflict on a
// constraint that no clause targets falls through to `stmt_level` (default
// ABORT), matching stock (multiple ON CONFLICT clauses, SQLite 3.35+). Stock
// enforces candidate NOT NULL/CHECK even for a row that will conflict, so
// these are NOT blanket-IGNORE'd (bd-xa2qv, mirrors bd-aap9u on the rowid
// path).
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
let stmt_level: Option<ConflictAction> = stmt.or_conflict;
// Validate every clause's conflict target + DO UPDATE assignment/WHERE
// columns up front for error parity. Every emittable target shape is covered
// by the WITHOUT ROWID upsert probe: the PRIMARY KEY
// (`upsert_target_matches_without_rowid_primary_key`), an omitted target (any
// UNIQUE constraint), and an explicit secondary-UNIQUE index target
// (`find_upsert_target_index`, bd-yqjjx); a target that matches none raises
// the same error stock does.
for clause in &stmt.upsert {
if let Some(target) = clause.target.as_ref()
&& find_upsert_target_index(table, Some(target)).is_none()
&& !upsert_target_matches_without_rowid_primary_key(table, target)
{
return Err(CodegenError::SqlError(
"ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint".to_owned(),
));
}
if let UpsertAction::Update {
assignments,
where_clause,
} = &clause.action
{
for assign in assignments {
validate_assignment_target(table, &assign.target)?;
validate_upsert_expr_columns(&assign.value, table, target_alias)?;
}
if let Some(where_expr) = where_clause {
validate_upsert_expr_columns(where_expr, table, target_alias)?;
}
}
}
let end_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
emit_without_rowid_open_write(b, table, table_cursor);
match &stmt.source {
InsertSource::Values(rows) => {
if rows.is_empty() {
return Err(CodegenError::Unsupported("empty VALUES".to_owned()));
}
let target_mapping = build_insert_target_mapping(&stmt.columns, table)?;
let n_source_cols = rows[0].len();
let expected = target_mapping
.as_ref()
.map_or(n_cols, |m| m.expected_source_cols);
if n_source_cols != expected {
return Err(CodegenError::Unsupported(format!(
"table {} has {} columns but {} values were supplied",
table.name, expected, n_source_cols
)));
}
let source_regs = b.alloc_regs(n_source_cols as i32);
let mapped_regs = target_mapping.as_ref().map(|_| b.alloc_regs(n_cols as i32));
for row_values in rows {
if row_values.len() != n_source_cols {
return Err(CodegenError::SqlError(
"all VALUES must have the same number of terms".to_owned(),
));
}
for (i, val_expr) in row_values.iter().enumerate() {
emit_expr(b, val_expr, source_regs + i as i32, None);
}
let val_regs = if let Some(mapping) = target_mapping.as_ref() {
let table_regs = mapped_regs.expect("mapped registers allocated");
for (tbl_idx, src) in mapping.col_mapping.iter().enumerate() {
let dest = table_regs + tbl_idx as i32;
if let Some(pos) = src {
b.emit_op(
Opcode::Copy,
source_regs + *pos as i32,
dest,
0,
P4::None,
0,
);
} else {
emit_default_value(b, &table.columns[tbl_idx], dest)?;
}
}
table_regs
} else {
source_regs
};
emit_without_rowid_row_insert(
b,
table,
table_cursor,
val_regs,
&pk_indices,
oe_flag,
stmt_level,
&stmt.upsert,
target_alias,
&stmt.returning,
schema,
)?;
}
}
InsertSource::DefaultValues => {
let val_regs = b.alloc_regs(n_cols as i32);
for (idx, col) in table.columns.iter().enumerate() {
emit_default_value(b, col, val_regs + idx as i32)?;
}
emit_without_rowid_row_insert(
b,
table,
table_cursor,
val_regs,
&pk_indices,
oe_flag,
stmt_level,
&stmt.upsert,
target_alias,
&stmt.returning,
schema,
)?;
}
InsertSource::Select(select_stmt) => {
let target_mapping = build_insert_target_mapping(&stmt.columns, table)?;
emit_without_rowid_insert_select(
b,
stmt,
select_stmt,
table,
schema,
&pk_indices,
oe_flag,
stmt_level,
&stmt.upsert,
target_alias,
target_mapping.as_ref(),
table_cursor,
)?;
}
}
emit_without_rowid_close(b, table, table_cursor);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// `INSERT ... SELECT` into a WITHOUT ROWID table. Scans a single source table
/// (or evaluates a FROM-less constant projection), maps the projected columns
/// into table-declared order (honoring an explicit column list, filling the
/// rest with DEFAULTs), and routes each row through
/// [`emit_without_rowid_row_insert`] so conflict handling, constraints, index
/// maintenance, and RETURNING are shared with the VALUES path.
///
/// Only the shapes the rowid `codegen_insert_select` itself supports are
/// accepted: a single named source table or no FROM. CTEs, compound
/// (UNION/INTERSECT/EXCEPT), ORDER BY/LIMIT, DISTINCT/GROUP BY/HAVING/window,
/// joins, subquery/VALUES sources, and `source == target` are rejected with a
/// clear error rather than silently mis-executed.
#[allow(
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn emit_without_rowid_insert_select(
b: &mut ProgramBuilder,
stmt: &InsertStatement,
select_stmt: &SelectStatement,
table: &TableSchema,
schema: &[TableSchema],
pk_indices: &[usize],
oe_flag: u16,
stmt_level: Option<ConflictAction>,
upserts: &[UpsertClause],
target_alias: Option<&str>,
target_mapping: Option<&InsertTargetMapping>,
table_cursor: i32,
) -> Result<(), CodegenError> {
if select_stmt.with.is_some() {
return Err(CodegenError::Unsupported(
"INSERT ... WITH ... SELECT into WITHOUT ROWID tables is not yet supported".to_owned(),
));
}
if !select_stmt.body.compounds.is_empty() {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with a compound (UNION/INTERSECT/EXCEPT) into WITHOUT ROWID tables \
is not yet supported"
.to_owned(),
));
}
if !select_stmt.order_by.is_empty() || select_stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with ORDER BY/LIMIT into WITHOUT ROWID tables is not yet supported"
.to_owned(),
));
}
let (distinct, columns, from, where_clause, group_by, having, windows) =
match &select_stmt.body.select {
SelectCore::Select {
distinct,
columns,
from,
where_clause,
group_by,
having,
windows,
} => (
distinct,
columns,
from,
where_clause,
group_by,
having,
windows,
),
SelectCore::Values(_) => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with a VALUES body into WITHOUT ROWID tables is not yet \
supported"
.to_owned(),
));
}
};
if *distinct != Distinctness::All
|| !group_by.is_empty()
|| having.is_some()
|| !windows.is_empty()
{
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with DISTINCT/GROUP BY/HAVING/window into WITHOUT ROWID tables is \
not yet supported"
.to_owned(),
));
}
let n_cols = table.columns.len();
let expected = target_mapping.map_or(n_cols, |m| m.expected_source_cols);
let returning = &stmt.returning;
// Reorder a SELECT-output row (`sel_regs`, `n_sel` columns) into
// table-declared order, filling unmentioned columns with DEFAULTs, then run
// the shared WITHOUT ROWID per-row insert. Returns nothing; emits opcodes.
// Implemented inline in both branches below because the borrow of `b` cannot
// be shared across a closure that also calls other `&mut b` helpers.
if let Some(from_clause) = from {
if !from_clause.joins.is_empty() {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT with a JOIN into WITHOUT ROWID tables is not yet supported"
.to_owned(),
));
}
let (src_name, src_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table { name, alias, .. } => {
(&name.name, alias.as_deref())
}
_ => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT from a non-table source into WITHOUT ROWID tables is not \
yet supported"
.to_owned(),
));
}
};
if src_name.eq_ignore_ascii_case(&table.name) {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT where the source is the target WITHOUT ROWID table is not yet \
supported"
.to_owned(),
));
}
let src_table = find_table(schema, src_name)?;
let read_cursor = table.indexes.len() as i32 + 1;
let n_sel = result_column_count(columns, src_table);
if n_sel as usize != expected {
return Err(CodegenError::Unsupported(format!(
"table {} has {} columns but {} values were supplied",
table.name, expected, n_sel
)));
}
b.emit_op(
Opcode::OpenRead,
read_cursor,
src_table.root_page,
0,
P4::Table(src_table.name.clone()),
0,
);
let done_label = b.emit_label();
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, read_cursor, 0, done_label, P4::None, 0);
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
read_cursor,
src_table,
src_alias,
schema,
skip_label,
);
}
let sel_regs = b.alloc_regs(n_sel);
emit_column_reads(
b,
read_cursor,
columns,
src_table,
src_alias,
schema,
sel_regs,
)?;
let val_regs =
map_insert_select_row_to_table_order(b, table, target_mapping, sel_regs, n_cols)?;
emit_without_rowid_row_insert(
b,
table,
table_cursor,
val_regs,
pk_indices,
oe_flag,
stmt_level,
upserts,
target_alias,
returning,
schema,
)?;
b.resolve_label(skip_label);
b.emit_op(
Opcode::Next,
read_cursor,
(loop_start + 1) as i32,
0,
P4::None,
0,
);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, read_cursor, 0, 0, P4::None, 0);
} else {
// FROM-less constant projection: a single row (optionally gated by a
// constant WHERE predicate).
let n_sel = result_column_count_without_from(columns)?;
if n_sel as usize != expected {
return Err(CodegenError::Unsupported(format!(
"table {} has {} columns but {} values were supplied",
table.name, expected, n_sel
)));
}
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
let pred = b.alloc_temp();
emit_expr(b, where_expr, pred, None);
b.emit_jump_to_label(Opcode::IfNot, pred, 1, skip_label, P4::None, 0);
b.free_temp(pred);
}
let sel_regs = b.alloc_regs(n_sel);
emit_projection_without_from(b, columns, sel_regs)?;
let val_regs =
map_insert_select_row_to_table_order(b, table, target_mapping, sel_regs, n_cols)?;
emit_without_rowid_row_insert(
b,
table,
table_cursor,
val_regs,
pk_indices,
oe_flag,
stmt_level,
upserts,
target_alias,
returning,
schema,
)?;
b.resolve_label(skip_label);
}
Ok(())
}
/// Reorder a SELECT-output row (`sel_regs`, in SELECT order) into table-declared
/// column order for an `INSERT ... SELECT` with an explicit column list, filling
/// unmentioned columns with their DEFAULTs. With no explicit column list the
/// SELECT row is already in table order and `sel_regs` is returned unchanged.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn map_insert_select_row_to_table_order(
b: &mut ProgramBuilder,
table: &TableSchema,
target_mapping: Option<&InsertTargetMapping>,
sel_regs: i32,
n_cols: usize,
) -> Result<i32, CodegenError> {
let Some(mapping) = target_mapping else {
return Ok(sel_regs);
};
let table_regs = b.alloc_regs(n_cols as i32);
for (tbl_idx, src) in mapping.col_mapping.iter().enumerate() {
let dest = table_regs + tbl_idx as i32;
if let Some(pos) = src {
b.emit_op(Opcode::Copy, sel_regs + *pos as i32, dest, 0, P4::None, 0);
} else {
emit_default_value(b, &table.columns[tbl_idx], dest)?;
}
}
Ok(table_regs)
}
/// Emit pass 1 of a WITHOUT ROWID UPDATE/DELETE: scan the table, apply the WHERE
/// filter, and collect each matching row's full record into a sorter (so the
/// table b-tree is not mutated mid-scan). Returns the sorter cursor id.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_without_rowid_collect_matches(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
schema: &[TableSchema],
where_clause: Option<&Expr>,
table_alias: Option<&str>,
// Anon-placeholder number the WHERE filter starts from. For DELETE this is
// the current counter (1); for UPDATE it is `set_placeholder_count + 1`,
// because the SET assignments (Pass 2) appear first in the SQL text but are
// emitted *after* this collect pass (bd-q3hu3).
where_placeholder_base: u32,
) -> i32 {
let n_cols = table.columns.len();
let sorter_cursor = table.indexes.len() as i32 + 1;
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
n_cols as i32,
0,
P4::Str("+".repeat(n_cols)),
0,
);
let scan_start = b.current_addr();
let scan_done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, table_cursor, 0, scan_done, P4::None, 0);
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
b.set_next_anon_placeholder(where_placeholder_base);
emit_where_filter(
b,
where_expr,
table_cursor,
table,
table_alias,
schema,
skip_label,
);
}
let row_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
table_cursor,
i as i32,
row_regs + i as i32,
P4::None,
0,
);
}
let rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
row_regs,
n_cols as i32,
rec_reg,
P4::None,
0,
);
b.emit_op(Opcode::SorterInsert, sorter_cursor, rec_reg, 0, P4::None, 0);
b.resolve_label(skip_label);
b.emit_op(
Opcode::Next,
table_cursor,
(scan_start + 1) as i32,
0,
P4::None,
0,
);
b.resolve_label(scan_done);
sorter_cursor
}
/// Code generation for DELETE from a WITHOUT ROWID table.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn codegen_delete_without_rowid(
b: &mut ProgramBuilder,
stmt: &DeleteStatement,
table: &TableSchema,
schema: &[TableSchema],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
if !stmt.order_by.is_empty() || stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"DELETE ORDER BY/LIMIT/OFFSET must be materialized before codegen".to_owned(),
));
}
if let Some(where_expr) = &stmt.where_clause {
validate_single_table_expr_columns(where_expr, table, stmt.table.alias.as_deref())?;
}
let pk_indices = without_rowid_pk_indices(table)?;
let table_cursor = 0_i32;
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
let end_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
emit_without_rowid_open_write(b, table, table_cursor);
// DELETE has no SET clause, so the WHERE placeholders keep their natural
// numbering (the current counter, 1).
let where_placeholder_base = b.current_anon_placeholder();
let sorter_cursor = emit_without_rowid_collect_matches(
b,
table,
table_cursor,
schema,
stmt.where_clause.as_ref(),
stmt.table.alias.as_deref(),
where_placeholder_base,
);
// Pass 2: re-seek each collected row by primary key and delete it.
let pass2_done = b.emit_label();
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
pass2_done,
P4::None,
0,
);
let loop_body = b.current_addr();
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
let row_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
sorter_cursor,
i as i32,
row_regs + i as i32,
P4::None,
0,
);
}
let pk_probe_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
row_regs + pk_col as i32,
pk_probe_regs + j as i32,
0,
P4::None,
0,
);
}
let pk_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_probe_regs,
n_pk as i32,
pk_probe_rec,
P4::None,
0,
);
let not_found = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
pk_probe_rec,
not_found,
P4::None,
0,
);
emit_without_rowid_index_deletes(b, table, table_cursor, Some(row_regs), &pk_indices);
b.emit_op(Opcode::IdxDelete, table_cursor, 0, 0, P4::None, 1);
// RETURNING: emit the deleted row image (row_regs holds the OLD values read
// from the sorter). Only reached when the row was actually found+deleted.
if !stmt.returning.is_empty() {
emit_returning_from_regs(
b,
table,
&stmt.returning,
stmt.table.alias.as_deref(),
schema,
row_regs,
)?;
}
b.resolve_label(not_found);
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
loop_body as i32,
0,
P4::None,
0,
);
b.resolve_label(pass2_done);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
emit_without_rowid_close(b, table, table_cursor);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Code generation for UPDATE of a WITHOUT ROWID table.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn codegen_update_without_rowid(
b: &mut ProgramBuilder,
stmt: &UpdateStatement,
table: &TableSchema,
schema: &[TableSchema],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
if let Some(from_clause) = &stmt.from {
return codegen_update_from_without_rowid(b, stmt, from_clause, table, schema, _ctx);
}
if !stmt.order_by.is_empty() || stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"UPDATE ORDER BY/LIMIT/OFFSET must be materialized before codegen".to_owned(),
));
}
for assign in &stmt.assignments {
validate_single_table_expr_columns(&assign.value, table, stmt.table.alias.as_deref())?;
}
if let Some(where_expr) = &stmt.where_clause {
validate_single_table_expr_columns(where_expr, table, stmt.table.alias.as_deref())?;
}
let pk_indices = without_rowid_pk_indices(table)?;
let table_cursor = 0_i32;
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
let end_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
emit_without_rowid_open_write(b, table, table_cursor);
// The WHERE filter (Pass 1) is emitted before the SET assignments (Pass 2),
// but the SET placeholders come first in the SQL text. Number the WHERE anon
// placeholders from `set_placeholder_count + 1` so a bound `UPDATE ... SET
// a=?, b=? WHERE k1=? AND k2=?` reads slots 3,4 in the WHERE — not 1,2, which
// silently seeked a non-existent key and matched 0 rows (bd-q3hu3). Mirrors
// the rowid `codegen_update` two-pass numbering.
let set_placeholder_count: u32 = stmt
.assignments
.iter()
.map(|a| count_anon_placeholders(&a.value))
.sum();
let sorter_cursor = emit_without_rowid_collect_matches(
b,
table,
table_cursor,
schema,
stmt.where_clause.as_ref(),
stmt.table.alias.as_deref(),
set_placeholder_count + 1,
);
// Pass 2: for each collected row, delete the old entry then insert the
// rewritten row. The column registers hold the OLD image first (used to
// remove the old table + index entries), then are overwritten by the
// assignments to become the NEW image for re-insertion.
let pass2_done = b.emit_label();
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
pass2_done,
P4::None,
0,
);
let loop_body = b.current_addr();
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
let col_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
sorter_cursor,
i as i32,
col_regs + i as i32,
P4::None,
0,
);
}
// Re-seek the OLD row by primary key (positions the table cursor).
let pk_probe_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
col_regs + pk_col as i32,
pk_probe_regs + j as i32,
0,
P4::None,
0,
);
}
let pk_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_probe_regs,
n_pk as i32,
pk_probe_rec,
P4::None,
0,
);
let row_done = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
table_cursor,
pk_probe_rec,
row_done,
P4::None,
0,
);
// bd-yuj70: preserve the OLD image before the assignments overwrite
// `col_regs`. The OLD secondary-index keys are derived from it *after* the
// NEW primary key has been probed, so no mutation happens before the
// conflict action is resolved.
let old_regs = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Copy,
col_regs + i as i32,
old_regs + i as i32,
0,
P4::None,
0,
);
}
// Compute the NEW image in place, then validate constraints. Nothing below
// this point has mutated the table yet, so OR IGNORE can bail cleanly and
// the ABORT family can raise with the OLD row still intact.
let update_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: stmt.table.alias.as_deref(),
schema: Some(schema),
register_base: Some(col_regs),
secondaries: &[],
};
// Reset the placeholder counter to 1 for the SET expressions (Pass 2): they
// appear first in the SQL text, so their anon placeholders are slots 1..N
// (bd-q3hu3).
b.set_next_anon_placeholder(1);
emit_update_assignments(b, &stmt.assignments, table, col_regs, &update_ctx)?;
emit_stored_generated_columns(b, table, col_regs);
emit_strict_type_check(b, table, col_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
let ignore_skip = if oe_flag == OE_IGNORE {
Some(row_done)
} else {
None
};
emit_check_constraints(b, table, col_regs, ignore_skip);
emit_not_null_constraints(b, table, col_regs, stmt.or_conflict, ignore_skip);
// Apply column affinities before the primary-key probe so the comparison
// and the stored record agree on representation.
b.emit_op(
Opcode::Affinity,
col_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_without_rowid_update_rewrite(
b,
table,
table_cursor,
col_regs,
old_regs,
pk_probe_regs,
&pk_indices,
oe_flag,
stmt.or_conflict,
row_done,
);
// RETURNING: emit the NEW row image (col_regs now holds the rewritten row).
if !stmt.returning.is_empty() {
emit_returning_from_regs(
b,
table,
&stmt.returning,
stmt.table.alias.as_deref(),
schema,
col_regs,
)?;
}
b.resolve_label(row_done);
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
loop_body as i32,
0,
P4::None,
0,
);
b.resolve_label(pass2_done);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
emit_without_rowid_close(b, table, table_cursor);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
/// Code generation for `UPDATE <wr_table> SET ... FROM <sources> [WHERE ...]`
/// where the target is a WITHOUT ROWID table.
///
/// A nested-loop join (FROM sources outer, target inner) mirrors the rowid
/// [`codegen_update_from`], but because the WITHOUT ROWID table IS the index
/// b-tree being scanned, mutating it mid-scan would invalidate the cursor. So
/// this is a two-pass strategy: pass 1 runs the join, and for every matching
/// (target, source) combination collects the `[OLD image || NEW image]` into a
/// sorter (the NEW image is computed while the FROM cursors are live, since the
/// assignments may reference FROM columns); pass 2 re-seeks each target by its
/// OLD primary key and rewrites it (delete OLD table + index entries, insert the
/// NEW row + index entries). Inner-join semantics; only named table sources.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_lines
)]
fn codegen_update_from_without_rowid(
b: &mut ProgramBuilder,
stmt: &UpdateStatement,
from_clause: &FromClause,
table: &TableSchema,
schema: &[TableSchema],
_ctx: &CodegenContext,
) -> Result<(), CodegenError> {
use fsqlite_ast::{JoinConstraint, JoinKind};
if !stmt.order_by.is_empty() || stmt.limit.is_some() {
return Err(CodegenError::Unsupported(
"UPDATE ORDER BY/LIMIT/OFFSET must be materialized before codegen".to_owned(),
));
}
// Resolve a single FROM source to (name, alias). Only named tables are
// supported (matching the rowid `codegen_update_from`).
fn resolve_from_table(src: &TableOrSubquery) -> Result<(&str, Option<&str>), CodegenError> {
match src {
TableOrSubquery::Table { name, alias, .. } => {
Ok((name.name.as_str(), alias.as_deref()))
}
TableOrSubquery::Subquery { .. } => Err(CodegenError::Unsupported(
"UPDATE ... FROM subquery sources are not yet supported on WITHOUT ROWID tables"
.to_owned(),
)),
_ => Err(CodegenError::Unsupported(
"UPDATE ... FROM only supports named tables on WITHOUT ROWID tables".to_owned(),
)),
}
}
// Collect FROM sources (leading + comma/JOIN) and any ON conditions.
let mut from_specs: Vec<(&str, Option<&str>)> = Vec::with_capacity(1 + from_clause.joins.len());
from_specs.push(resolve_from_table(&from_clause.source)?);
let mut on_conditions: Vec<&Expr> = Vec::new();
for join in &from_clause.joins {
if join.join_type.natural {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with NATURAL JOIN is not yet supported on WITHOUT ROWID tables"
.to_owned(),
));
}
match join.join_type.kind {
JoinKind::Inner | JoinKind::Cross => {}
JoinKind::Left | JoinKind::Right | JoinKind::Full => {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with an OUTER JOIN source is not yet supported on WITHOUT \
ROWID tables"
.to_owned(),
));
}
}
from_specs.push(resolve_from_table(&join.table)?);
match &join.constraint {
Some(JoinConstraint::On(expr)) => on_conditions.push(expr),
Some(JoinConstraint::Using(_)) => {
return Err(CodegenError::Unsupported(
"UPDATE ... FROM with a USING(...) join constraint is not yet supported on \
WITHOUT ROWID tables"
.to_owned(),
));
}
None => {}
}
}
let pk_indices = without_rowid_pk_indices(table)?;
let n_cols = table.columns.len();
let n_pk = pk_indices.len();
let n_indexes = table.indexes.len();
let target_cursor = 0_i32;
let oe_flag = conflict_action_to_oe(stmt.or_conflict.as_ref());
// Cursor allocation: 0 = target (write), 1..=n_indexes = target indexes,
// then one read cursor per FROM source, then the sorter.
let mut secondaries: Vec<SecondaryScan> = Vec::with_capacity(from_specs.len());
for (i, (src_name, src_alias)) in from_specs.iter().enumerate() {
let src_table = find_table(schema, src_name)?;
let cursor = (1 + n_indexes + i) as i32;
secondaries.push(SecondaryScan {
cursor,
table: src_table,
table_alias: *src_alias,
register_base: None,
});
}
let sorter_cursor = (1 + n_indexes + from_specs.len()) as i32;
let end_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 1, 0, P4::None, 0);
// Validate all column references across the target + FROM sources.
let scan = ScanCtx {
cursor: target_cursor,
table,
table_alias: stmt.table.alias.as_deref(),
schema: Some(schema),
register_base: None,
secondaries: &secondaries,
};
for assign in &stmt.assignments {
validate_scan_expr_columns(&assign.value, &scan)?;
}
for cond in &on_conditions {
validate_scan_expr_columns(cond, &scan)?;
}
if let Some(where_expr) = &stmt.where_clause {
validate_scan_expr_columns(where_expr, &scan)?;
}
validate_single_table_result_columns(
&stmt.returning,
table,
stmt.table.alias.as_deref(),
stmt.table.name.schema.as_deref(),
)?;
// Open target (write) + its index cursors, then the FROM read cursors.
emit_without_rowid_open_write(b, table, target_cursor);
for sec in &secondaries {
b.emit_op(
Opcode::OpenRead,
sec.cursor,
sec.table.root_page,
0,
P4::Table(sec.table.name.clone()),
0,
);
}
// Sorter holds [OLD image (n_cols) || NEW image (n_cols)].
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
(2 * n_cols) as i32,
0,
P4::Str("+".repeat(2 * n_cols)),
0,
);
// Anonymous-placeholder base counts in SQL textual order: SET, ON, WHERE,
// RETURNING.
let set_ph: u32 = stmt
.assignments
.iter()
.map(|a| count_anon_placeholders(&a.value))
.sum();
let on_ph: u32 = on_conditions
.iter()
.map(|e| count_anon_placeholders(e))
.sum();
let where_ph: u32 = stmt
.where_clause
.as_ref()
.map_or(0, count_anon_placeholders);
// --- Pass 1: nested-loop join (FROM outer, target inner); collect matches. ---
struct LoopFrame {
cursor: i32,
body: i32,
done: Label,
}
let mut frames: Vec<LoopFrame> = Vec::with_capacity(secondaries.len());
for sec in &secondaries {
let done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, sec.cursor, 0, done, P4::None, 0);
let body = b.current_addr() as i32;
frames.push(LoopFrame {
cursor: sec.cursor,
body,
done,
});
}
// Innermost: scan the target table.
let target_done_label = b.emit_label();
b.emit_jump_to_label(
Opcode::Rewind,
target_cursor,
0,
target_done_label,
P4::None,
0,
);
let target_body = b.current_addr() as i32;
// Filters: each ON condition (join order) then WHERE. A failed condition
// skips to the innermost (target) Next.
let skip_label = b.emit_label();
let filter_conditions: Vec<&Expr> = on_conditions
.iter()
.copied()
.chain(stmt.where_clause.as_ref())
.collect();
if !filter_conditions.is_empty() {
b.set_next_anon_placeholder(set_ph + 1);
for cond in &filter_conditions {
let cond_reg = b.alloc_temp();
emit_expr(b, cond, cond_reg, Some(&scan));
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
b.free_temp(cond_reg);
}
}
// Match: build the OLD image, seed the NEW image from it, apply the
// assignments (RHS resolves target cols via the cursor and FROM cols via the
// secondaries), validate, and stash [OLD || NEW] into the sorter.
let row_regs = b.alloc_regs((2 * n_cols) as i32);
let old_regs = row_regs;
let new_regs = row_regs + n_cols as i32;
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
target_cursor,
i as i32,
old_regs + i as i32,
P4::None,
0,
);
b.emit_op(
Opcode::Copy,
old_regs + i as i32,
new_regs + i as i32,
0,
P4::None,
0,
);
}
b.set_next_anon_placeholder(1);
emit_update_assignments(b, &stmt.assignments, table, new_regs, &scan)?;
emit_stored_generated_columns(b, table, new_regs);
emit_strict_type_check(b, table, new_regs);
// GH #169: coerce to column affinity before CHECK/NOT NULL so the
// constraints see the affinity-coerced value (SQLite applies affinity, then
// evaluates constraints).
b.emit_op(
Opcode::Affinity,
new_regs,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_check_constraints(b, table, new_regs, None);
emit_not_null_constraints(b, table, new_regs, stmt.or_conflict, None);
let stash_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
row_regs,
(2 * n_cols) as i32,
stash_rec,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sorter_cursor,
stash_rec,
0,
P4::None,
0,
);
b.resolve_label(skip_label);
b.emit_op(Opcode::Next, target_cursor, target_body, 0, P4::None, 0);
b.resolve_label(target_done_label);
// Unwind the FROM-source loops from innermost to outermost.
for frame in frames.iter().rev() {
b.emit_op(Opcode::Next, frame.cursor, frame.body, 0, P4::None, 0);
b.resolve_label(frame.done);
}
// The FROM read cursors are no longer needed once the collect pass is done.
for sec in &secondaries {
b.emit_op(Opcode::Close, sec.cursor, 0, 0, P4::None, 0);
}
// --- Pass 2: re-seek each collected row by OLD primary key and rewrite. ---
let pass2_done = b.emit_label();
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
pass2_done,
P4::None,
0,
);
let loop_body = b.current_addr();
let sorted_reg = b.alloc_reg();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
let old_img = b.alloc_regs(n_cols as i32);
let new_img = b.alloc_regs(n_cols as i32);
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
sorter_cursor,
i as i32,
old_img + i as i32,
P4::None,
0,
);
}
for i in 0..n_cols {
b.emit_op(
Opcode::Column,
sorter_cursor,
(n_cols + i) as i32,
new_img + i as i32,
P4::None,
0,
);
}
// Re-seek the OLD row by primary key; NoConflict skips if it is gone.
let pk_probe_regs = b.alloc_regs(n_pk as i32);
for (j, &pk_col) in pk_indices.iter().enumerate() {
b.emit_op(
Opcode::Copy,
old_img + pk_col as i32,
pk_probe_regs + j as i32,
0,
P4::None,
0,
);
}
let pk_probe_rec = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
pk_probe_regs,
n_pk as i32,
pk_probe_rec,
P4::None,
0,
);
let row_done = b.emit_label();
b.emit_jump_to_label(
Opcode::NoConflict,
target_cursor,
pk_probe_rec,
row_done,
P4::None,
0,
);
// bd-yuj70: apply affinities before probing, then rewrite through the
// shared victim-safe path. UPDATE FROM computed the NEW image during pass 1
// while the FROM cursors were live, so only conflict resolution and the
// OLD/NEW mutation order are shared with the plain UPDATE emission.
b.emit_op(
Opcode::Affinity,
new_img,
n_cols as i32,
0,
P4::Affinity(table.affinity_string()),
0,
);
emit_without_rowid_update_rewrite(
b,
table,
target_cursor,
new_img,
old_img,
pk_probe_regs,
&pk_indices,
oe_flag,
stmt.or_conflict,
row_done,
);
// RETURNING: the NEW image.
if !stmt.returning.is_empty() {
b.set_next_anon_placeholder(set_ph + on_ph + where_ph + 1);
emit_returning_from_regs(
b,
table,
&stmt.returning,
stmt.table.alias.as_deref(),
schema,
new_img,
)?;
}
b.resolve_label(row_done);
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
loop_body as i32,
0,
P4::None,
0,
);
b.resolve_label(pass2_done);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
emit_without_rowid_close(b, table, target_cursor);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
Ok(())
}
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
/// Emit a column's DEFAULT value into a register.
///
/// Parses the column's `default_value` SQL text and emits the appropriate
/// opcode. Emits `Null` when no default is specified.
fn emit_default_value(
b: &mut ProgramBuilder,
col: &ColumnInfo,
reg: i32,
) -> Result<(), CodegenError> {
match col.default_value.as_deref() {
None => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
Ok(())
}
Some(dv) => {
let expr = parse_default_expr(dv).ok_or_else(|| {
CodegenError::Unsupported(format!(
"failed to parse DEFAULT expression `{}` for column `{}`",
dv.trim(),
col.name
))
})?;
if !default_expr_is_self_contained(&expr) {
return Err(CodegenError::Unsupported(format!(
"DEFAULT expression `{}` for column `{}` is not self-contained",
dv.trim(),
col.name
)));
}
emit_expr(b, &expr, reg, None);
Ok(())
}
}
}
/// Parse column DEFAULT SQL text into an expression AST.
fn parse_default_expr(default_sql: &str) -> Option<Expr> {
let trimmed = default_sql.trim();
if trimmed.is_empty() {
return None;
}
parse_sql_expr(trimmed).ok()
}
fn default_expr_is_self_contained(expr: &Expr) -> bool {
match expr {
Expr::Literal(_, _) => true,
Expr::BoundOuterValue { .. }
| Expr::Column(_, _)
| Expr::Exists { .. }
| Expr::Subquery(_, _)
| Expr::Raise { .. }
| Expr::RowValue(_, _)
| Expr::Placeholder(_, _) => false,
Expr::BinaryOp { left, right, .. } => {
default_expr_is_self_contained(left) && default_expr_is_self_contained(right)
}
Expr::UnaryOp { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. }
| Expr::IsNull { expr: inner, .. } => default_expr_is_self_contained(inner),
Expr::Between {
expr: inner,
low,
high,
..
} => {
default_expr_is_self_contained(inner)
&& default_expr_is_self_contained(low)
&& default_expr_is_self_contained(high)
}
Expr::In {
expr: inner, set, ..
} => {
default_expr_is_self_contained(inner)
&& match set {
fsqlite_ast::InSet::List(exprs) => {
exprs.iter().all(default_expr_is_self_contained)
}
fsqlite_ast::InSet::Subquery(_) | fsqlite_ast::InSet::Table(_) => false,
}
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
default_expr_is_self_contained(inner)
&& default_expr_is_self_contained(pattern)
&& escape.as_deref().is_none_or(default_expr_is_self_contained)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_none_or(default_expr_is_self_contained)
&& whens.iter().all(|(when_expr, then_expr)| {
default_expr_is_self_contained(when_expr)
&& default_expr_is_self_contained(then_expr)
})
&& else_expr
.as_deref()
.is_none_or(default_expr_is_self_contained)
}
Expr::FunctionCall {
args,
distinct,
order_by,
filter,
over,
..
} => {
!distinct
&& order_by.is_empty()
&& filter.is_none()
&& over.is_none()
&& match args {
fsqlite_ast::FunctionArgs::Star => false,
fsqlite_ast::FunctionArgs::List(exprs) => {
exprs.iter().all(default_expr_is_self_contained)
}
}
}
Expr::JsonAccess {
expr: inner, path, ..
} => default_expr_is_self_contained(inner) && default_expr_is_self_contained(path),
}
}
fn emit_index_predicate_guard(
b: &mut ProgramBuilder,
index: &IndexSchema,
scan_ctx: &ScanCtx<'_>,
skip_label: Label,
) {
let Some(where_sql) = index.where_clause.as_deref() else {
return;
};
let Some(predicate) = parse_default_expr(where_sql) else {
// Invalid persisted predicate metadata should not panic compilation.
// Conservatively skip maintenance for this row.
b.emit_jump_to_label(Opcode::Goto, 0, 0, skip_label, P4::None, 0);
return;
};
let result_reg = b.alloc_reg();
b.with_schema_evaluation_context(SchemaEvaluationContext::Index, |b| {
emit_expr(b, &predicate, result_reg, Some(scan_ctx));
});
// Partial indexes include only rows where the predicate is true.
b.emit_jump_to_label(Opcode::IfNot, result_reg, 1, skip_label, P4::None, 0);
}
fn emit_index_key_term(
b: &mut ProgramBuilder,
index: &IndexSchema,
key_pos: usize,
dest_reg: i32,
scan_ctx: &ScanCtx<'_>,
) {
let Some(term_sql) = index.key_term_sql(key_pos) else {
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
return;
};
if let Some(expr) = parse_default_expr(term_sql) {
b.with_schema_evaluation_context(SchemaEvaluationContext::Index, |b| {
emit_expr(b, &expr, dest_reg, Some(scan_ctx));
});
} else {
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
}
}
/// Evaluate STORED generated column expressions during INSERT/UPDATE.
///
/// For each column with `generated_stored == Some(true)`, parses the stored
/// expression SQL, evaluates it using register-based column resolution, and
/// writes the result into the corresponding column register.
///
/// VIRTUAL generated columns are set to NULL (they are computed at SELECT time).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_stored_generated_columns(b: &mut ProgramBuilder, table: &TableSchema, val_regs: i32) {
for (col_idx, col) in table.columns.iter().enumerate() {
let Some(ref expr_sql) = col.generated_expr else {
continue;
};
let dest_reg = val_regs + col_idx as i32;
if col.generated_stored == Some(true) {
// STORED: evaluate expression and write result to register.
if let Some(expr) = parse_default_expr(expr_sql) {
let gen_ctx = ScanCtx {
cursor: 0,
table,
table_alias: None,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
b.with_schema_evaluation_context(SchemaEvaluationContext::GeneratedColumn, |b| {
emit_expr(b, &expr, dest_reg, Some(&gen_ctx));
});
} else {
// Expression parse failed — store NULL.
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
}
} else {
// VIRTUAL: not stored in the record; set NULL as placeholder.
// The real value is computed on read (bd-r3303,
// virtual_generated_column_expr).
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
}
}
}
/// bd-r3303: if `col` is a VIRTUAL generated column, parse and return its
/// generating expression so callers can compute it on read.
///
/// Returns `None` for non-generated columns and for STORED generated columns
/// (which are computed and persisted at write time, then read back directly).
fn virtual_generated_column_expr(col: &ColumnInfo) -> Option<Expr> {
if col.generated_stored != Some(false) {
return None;
}
parse_default_expr(col.generated_expr.as_ref()?)
}
/// bd-r3303: emit `Opcode::Affinity` to coerce a single register to a column's
/// declared type affinity. Used after computing a VIRTUAL generated column on
/// read, mirroring the per-column affinity that record packing applies to
/// STORED columns at write time.
fn emit_single_column_affinity(b: &mut ProgramBuilder, reg: i32, affinity: char) {
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Affinity(affinity.to_string()),
0,
);
}
/// Emit the read of table column `col_idx` from `cursor` into `reg`.
///
/// Centralizes the three column-read shapes so every table-scan read site
/// (projection, WHERE, ORDER BY) handles them uniformly:
/// - INTEGER PRIMARY KEY columns read the rowid (`Opcode::Rowid`);
/// - VIRTUAL generated columns (bd-r3303) are not materialized in the record,
/// so compute the generating expression against the current row (the
/// referenced base columns resolve through this same path) and coerce to the
/// column's declared affinity, matching what record packing applies to STORED
/// columns at write time;
/// - all other columns read directly from the record (`Opcode::Column`).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_table_column_read(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: Option<&[TableSchema]>,
col_idx: usize,
reg: i32,
) {
let col = &table.columns[col_idx];
if col.is_ipk {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
} else if let Some(gen_expr) = virtual_generated_column_expr(col) {
let scan = ScanCtx {
cursor,
table,
table_alias,
schema,
register_base: None,
secondaries: &[],
};
b.with_schema_evaluation_context(SchemaEvaluationContext::GeneratedColumn, |b| {
emit_expr(b, &gen_expr, reg, Some(&scan));
});
emit_single_column_affinity(b, reg, col.affinity);
} else {
b.emit_op(Opcode::Column, cursor, col_idx as i32, reg, P4::None, 0);
}
}
/// Emit CHECK constraint validation for INSERT/UPDATE.
///
/// For each CHECK constraint on the table, parses the constraint expression,
/// evaluates it using register-based column resolution, and emits a `Halt`
/// with SQLITE_CONSTRAINT (19) if any constraint evaluates to false (0).
/// NULL results are treated as passing (SQLite semantics: CHECK passes
/// unless the expression is explicitly false).
///
/// When `ignore_label` is `Some`, CHECK failures jump there instead of
/// halting (used for INSERT OR IGNORE to silently skip violating rows).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_check_constraints(
b: &mut ProgramBuilder,
table: &TableSchema,
val_regs: i32,
ignore_label: Option<Label>,
) {
const SQLITE_CONSTRAINT: i32 = 19;
for check in &table.check_constraints {
let Some(expr) = parse_default_expr(&check.expr) else {
continue;
};
let result_reg = b.alloc_reg();
let ok_label = b.emit_label();
let check_ctx = ScanCtx {
cursor: 0,
table,
table_alias: None,
schema: None,
register_base: Some(val_regs),
secondaries: &[],
};
b.with_schema_evaluation_context(SchemaEvaluationContext::CheckConstraint, |b| {
emit_expr(b, &expr, result_reg, Some(&check_ctx));
});
// NULL result: CHECK passes (SQLite semantics).
b.emit_jump_to_label(Opcode::IsNull, result_reg, 0, ok_label, P4::None, 0);
// Non-zero (truthy): CHECK passes.
b.emit_jump_to_label(Opcode::If, result_reg, 0, ok_label, P4::None, 0);
// False (0): CHECK fails.
if let Some(skip) = ignore_label {
// OR IGNORE: skip this row silently.
b.emit_jump_to_label(Opcode::Goto, 0, 0, skip, P4::None, 0);
} else {
// Default: halt with constraint error.
b.emit_op(
Opcode::Halt,
SQLITE_CONSTRAINT,
0,
0,
P4::Str(match &check.name {
Some(name) => format!("CHECK constraint failed: {name}"),
None => format!("CHECK constraint failed: {}", check.expr),
}),
0,
);
}
b.resolve_label(ok_label);
}
}
/// Emit NOT NULL constraint validation for INSERT/UPDATE.
///
/// For each column with `not_null == true` (and not an IPK, which can't be NULL),
/// emits `HaltIfNull` to abort with SQLITE_CONSTRAINT if the value is NULL.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_not_null_constraints(
b: &mut ProgramBuilder,
table: &TableSchema,
val_regs: i32,
stmt_level: Option<ConflictAction>,
ignore_label: Option<Label>,
) {
const SQLITE_CONSTRAINT: i32 = 19;
// In a WITHOUT ROWID table the PRIMARY KEY columns are implicitly NOT NULL
// even when NOT NULL is not declared (C SQLite enforces this). For rowid
// tables this set is empty, so behavior is unchanged.
let wr_pk: Vec<usize> = if table.without_rowid {
without_rowid_pk_indices(table).unwrap_or_default()
} else {
Vec::new()
};
for (col_idx, col) in table.columns.iter().enumerate() {
if (col.notnull || wr_pk.contains(&col_idx)) && !col.is_ipk {
let reg = val_regs + col_idx as i32;
let ok_label = b.emit_label();
b.emit_jump_to_label(Opcode::NotNull, reg, 0, ok_label, P4::None, 0);
// A statement-level `INSERT OR <algo>` overrides the column's
// declared `NOT NULL ON CONFLICT <algo>`. Only IGNORE skips the
// row; every other action (incl. the default ABORT) errors.
let oe = effective_oe(stmt_level, col.conflict_action);
match (oe == OE_IGNORE, ignore_label) {
(true, Some(skip)) => {
b.emit_jump_to_label(Opcode::Goto, 0, 0, skip, P4::None, 0);
}
_ => {
b.emit_op(
Opcode::Halt,
SQLITE_CONSTRAINT,
0,
0,
P4::Str(format!(
"NOT NULL constraint failed: {}.{}",
table.name, col.name
)),
0,
);
}
}
b.resolve_label(ok_label);
}
}
}
/// Emit `IdxInsert` opcodes for all indexes on the table (bd-so1h: Phase 5I.3).
///
/// For each index, this reads the indexed column values from the provided
/// registers, appends the rowid, builds an index key record, and inserts it.
///
/// # Arguments
/// * `b` - Program builder
/// * `table` - Table schema (includes index definitions)
/// * `table_cursor` - Cursor ID for the table (index cursors are table_cursor + 1, +2, etc.)
/// * `col_regs` - Starting register containing column values in table schema order
/// * `rowid_reg` - Register containing the rowid
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_inserts(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
col_regs: i32,
rowid_reg: i32,
stmt_conflict: Option<ConflictAction>,
) {
emit_index_inserts_filtered(
b,
table,
table_cursor,
col_regs,
rowid_reg,
stmt_conflict,
None,
);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_inserts_for_update(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
col_regs: i32,
rowid_reg: i32,
stmt_conflict: Option<ConflictAction>,
update_index_mask: &[bool],
) {
emit_index_inserts_filtered(
b,
table,
table_cursor,
col_regs,
rowid_reg,
stmt_conflict,
Some(update_index_mask),
);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_inserts_filtered(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
col_regs: i32,
rowid_reg: i32,
stmt_conflict: Option<ConflictAction>,
update_index_mask: Option<&[bool]>,
) {
for (idx_offset, index) in table.indexes.iter().enumerate() {
// A statement-level `INSERT OR <algo>` overrides the index's declared
// `ON CONFLICT <algo>`; absent both, the default is ABORT.
let oe_flag = effective_oe(stmt_conflict, index.conflict_action);
if update_index_mask.is_some_and(|mask| !mask.get(idx_offset).copied().unwrap_or(true)) {
continue;
}
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
let skip_label = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: Some(col_regs),
secondaries: &[],
};
emit_index_predicate_guard(b, index, &scan_ctx, skip_label);
// Allocate registers for index key: (indexed_cols..., rowid).
let idx_key_regs = b.alloc_regs((n_idx_cols + 1) as i32);
// Evaluate indexed key terms into the key registers.
for key_pos in 0..n_idx_cols {
let dst_reg = idx_key_regs + key_pos as i32;
emit_index_key_term(b, index, key_pos, dst_reg, &scan_ctx);
}
// Append rowid as the final key component.
let rowid_key_reg = idx_key_regs + n_idx_cols as i32;
b.emit_op(Opcode::Copy, rowid_reg, rowid_key_reg, 0, P4::None, 0);
// Build the index key record.
let idx_rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
idx_key_regs,
(n_idx_cols + 1) as i32,
idx_rec_reg,
P4::None,
0,
);
// Insert into the index.
// For UNIQUE indexes, set P5=1 and P3=number of indexed columns
// (excluding the trailing rowid) so the engine can enforce the
// uniqueness constraint while allowing multiple NULLs.
let (p3_unique, p5_unique) = if index.is_unique {
(n_idx_cols as i32, 1 | (oe_flag << 1))
} else {
(0, 0)
};
let p4_name = if index.is_unique {
// Include table name for the error message.
P4::Table(index.key_label_qualified(&table.name))
} else {
P4::Table(index.name.clone())
};
b.emit_op(
Opcode::IdxInsert,
idx_cursor,
idx_rec_reg,
p3_unique,
p4_name,
p5_unique,
);
b.resolve_label(skip_label);
}
}
/// Emit `IdxDelete` opcodes for all indexes on the table (bd-34se: Phase 5I.4).
///
/// For each index, this reads the indexed column values from the cursor,
/// reads the rowid, and emits `IdxDelete` with `(p2, p3)` pointing at the key
/// register span. The VDBE engine seeks to that key before deleting.
/// MUST be called BEFORE the table row is deleted, while data is still accessible.
///
/// # Arguments
/// * `b` - Program builder
/// * `table` - Table schema (includes index definitions)
/// * `table_cursor` - Cursor ID for the table (index cursors are table_cursor + 1, +2, etc.)
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_deletes(b: &mut ProgramBuilder, table: &TableSchema, table_cursor: i32) {
emit_index_deletes_filtered(b, table, table_cursor, None);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_deletes_for_update(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
update_index_mask: &[bool],
) {
emit_index_deletes_filtered(b, table, table_cursor, Some(update_index_mask));
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_index_deletes_filtered(
b: &mut ProgramBuilder,
table: &TableSchema,
table_cursor: i32,
update_index_mask: Option<&[bool]>,
) {
for (idx_offset, index) in table.indexes.iter().enumerate() {
if update_index_mask.is_some_and(|mask| !mask.get(idx_offset).copied().unwrap_or(true)) {
continue;
}
let idx_cursor = table_cursor + 1 + idx_offset as i32;
let n_idx_cols = index.key_term_count();
let skip_label = b.emit_label();
let scan_ctx = ScanCtx {
cursor: table_cursor,
table,
table_alias: None,
schema: None,
register_base: None,
secondaries: &[],
};
emit_index_predicate_guard(b, index, &scan_ctx, skip_label);
// Allocate registers for index key: (indexed_cols..., rowid).
let idx_key_regs = b.alloc_regs((n_idx_cols + 1) as i32);
// Re-evaluate indexed key terms from the current row.
for key_pos in 0..n_idx_cols {
let dst_reg = idx_key_regs + key_pos as i32;
emit_index_key_term(b, index, key_pos, dst_reg, &scan_ctx);
}
// Read rowid and append as the final key component.
let rowid_key_reg = idx_key_regs + n_idx_cols as i32;
b.emit_op(Opcode::Rowid, table_cursor, rowid_key_reg, 0, P4::None, 0);
// Delete from the index.
b.emit_op(
Opcode::IdxDelete,
idx_cursor,
idx_key_regs,
(n_idx_cols + 1) as i32,
P4::Table(index.name.clone()),
0,
);
b.resolve_label(skip_label);
}
}
/// Register metadata mapping a table cursor to its index cursors and their
/// column indices. Used by the VDBE engine's REPLACE conflict resolution to
/// clean up secondary index entries when a conflicting row is deleted.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn register_table_index_meta(b: &mut ProgramBuilder, table: &TableSchema, table_cursor: i32) {
let metas: Vec<IndexCursorMeta> = table
.indexes
.iter()
.enumerate()
.map(|(idx_offset, index)| {
let cursor_id = table_cursor + 1 + idx_offset as i32;
// Direct, non-partial column indexes can reconstruct their exact
// key from the victim table payload. Partial and expression
// indexes cannot: an empty list tells the engine to locate the
// victim entry by its trailing rowid instead.
let column_indices = if index.supports_replace_cleanup_meta() {
index
.columns
.iter()
.filter_map(|col_name| table.column_index(col_name))
.collect()
} else {
Vec::new()
};
IndexCursorMeta {
cursor_id,
column_indices,
}
})
.collect();
b.register_table_indexes(table_cursor, metas);
}
/// Count result columns (handling `SELECT *`).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn result_column_count(columns: &[ResultColumn], table: &TableSchema) -> i32 {
result_column_count_usize(columns, table) as i32
}
fn result_column_count_usize(columns: &[ResultColumn], table: &TableSchema) -> usize {
let mut count = 0usize;
for col in columns {
match col {
ResultColumn::Star | ResultColumn::TableStar(_) => {
count += table.columns.len();
}
ResultColumn::Expr { .. } => count += 1,
}
}
count
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn result_column_count_without_from(columns: &[ResultColumn]) -> Result<i32, CodegenError> {
let mut count = 0i32;
for col in columns {
match col {
ResultColumn::Expr { .. } => count += 1,
ResultColumn::Star | ResultColumn::TableStar(_) => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT without FROM does not support `*` projections".to_owned(),
));
}
}
}
Ok(count)
}
/// Emit Column instructions to read result columns into registers.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_column_reads(
b: &mut ProgramBuilder,
cursor: i32,
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
base_reg: i32,
) -> Result<(), CodegenError> {
let scan = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_column_reads_selected(b, &scan, columns, base_reg, |_| true)
}
/// Emit selected flattened result columns into their normal output slots.
///
/// `SELECT *` and table-star columns each occupy one flattened slot per table
/// column. This lets ordered codegen evaluate an exact ORDER BY output
/// expression once at its key position, then emit only the remaining output
/// slots after top-N admission.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_column_reads_selected(
b: &mut ProgramBuilder,
scan: &ScanCtx<'_>,
columns: &[ResultColumn],
base_reg: i32,
mut should_emit: impl FnMut(usize) -> bool,
) -> Result<(), CodegenError> {
let cursor = scan.cursor;
let table = scan.table;
let table_alias = scan.table_alias;
let schema = scan.schema;
let mut reg = base_reg;
let mut output_slot = 0usize;
for col in columns {
match col {
ResultColumn::Star => {
for i in 0..table.columns.len() {
if should_emit(output_slot) {
emit_table_column_read(b, cursor, table, table_alias, schema, i, reg);
}
reg += 1;
output_slot += 1;
}
}
ResultColumn::TableStar(qualifier) => {
if !matches_table_or_alias(&qualifier.name, table, table_alias) {
return Err(CodegenError::TableNotFound(qualifier.to_string()));
}
for i in 0..table.columns.len() {
if should_emit(output_slot) {
emit_table_column_read(b, cursor, table, table_alias, schema, i, reg);
}
reg += 1;
output_slot += 1;
}
}
ResultColumn::Expr { expr, .. } => {
if should_emit(output_slot) {
if let Expr::Column(col_ref, _) = expr {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
if let Some(col_idx) = table.column_index(&col_ref.column) {
emit_table_column_read(
b,
cursor,
table,
table_alias,
schema,
col_idx,
reg,
);
} else if table.resolves_to_hidden_rowid(&col_ref.column) {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
} else if let Some(qualifier) = &col_ref.table {
return Err(qualified_column_not_found(qualifier, &col_ref.column));
} else {
return Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_ref.column.to_string(),
});
}
} else {
// Evaluate non-column expressions (literals, arithmetic, CASE, CAST, etc.)
// against the current scan row.
emit_expr(b, expr, reg, Some(scan));
}
}
reg += 1;
output_slot += 1;
}
}
}
Ok(())
}
/// Retain the representative source row needed by ordered DISTINCT's
/// second-stage projection.
///
/// Rowid tables need only their stable rowid because pass 2 keeps the source
/// cursor open and seeks the representative again. WITHOUT ROWID tables have
/// no such locator, so retain their complete physical row image. VIRTUAL
/// generated columns deliberately keep a NULL placeholder: register-backed
/// projection recomputes them from the retained base columns, preserving the
/// second-stage function and error behavior.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_ordered_distinct_source_state(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
state_base: i32,
) {
if !table.without_rowid {
b.emit_op(Opcode::Rowid, cursor, state_base, 0, P4::None, 0);
return;
}
for (column_index, column) in table.columns.iter().enumerate() {
let target = state_base + column_index as i32;
if virtual_generated_column_expr(column).is_some() {
b.emit_op(Opcode::Null, 0, target, 0, P4::None, 0);
} else {
b.emit_op(
Opcode::Column,
cursor,
column_index as i32,
target,
P4::None,
0,
);
}
}
}
/// Read one logical table column from a retained register row.
///
/// Physical and STORED-generated columns are copied directly. VIRTUAL
/// generated columns are recomputed against the same retained row image and
/// receive their declared affinity, just as a live table-cursor read does.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_table_column_read_from_register_row(
b: &mut ProgramBuilder,
table: &TableSchema,
table_alias: Option<&str>,
schema: Option<&[TableSchema]>,
source_base: i32,
column_index: usize,
target: i32,
) {
let column = &table.columns[column_index];
if let Some(generated_expr) = virtual_generated_column_expr(column) {
let scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema,
register_base: Some(source_base),
secondaries: &[],
};
b.with_schema_evaluation_context(SchemaEvaluationContext::GeneratedColumn, |b| {
emit_expr(b, &generated_expr, target, Some(&scan));
});
emit_single_column_affinity(b, target, column.affinity);
} else {
b.emit_op(
Opcode::Copy,
source_base + column_index as i32,
target,
0,
P4::None,
0,
);
}
}
/// Re-evaluate a flattened SELECT projection against a retained WITHOUT ROWID
/// source row.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_projection_from_register_row(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
source_base: i32,
output_base: i32,
) -> Result<(), CodegenError> {
let scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: Some(source_base),
secondaries: &[],
};
let mut target = output_base;
for column in columns {
match column {
ResultColumn::Star => {
for column_index in 0..table.columns.len() {
emit_table_column_read_from_register_row(
b,
table,
table_alias,
Some(schema),
source_base,
column_index,
target,
);
target += 1;
}
}
ResultColumn::TableStar(qualifier) => {
if !matches_table_or_alias(&qualifier.name, table, table_alias) {
return Err(CodegenError::TableNotFound(qualifier.to_string()));
}
for column_index in 0..table.columns.len() {
emit_table_column_read_from_register_row(
b,
table,
table_alias,
Some(schema),
source_base,
column_index,
target,
);
target += 1;
}
}
ResultColumn::Expr { expr, .. } => {
emit_expr(b, expr, target, Some(&scan));
target += 1;
}
}
}
Ok(())
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_projection_without_from(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
base_reg: i32,
) -> Result<(), CodegenError> {
let mut reg = base_reg;
for col in columns {
match col {
ResultColumn::Expr { expr, .. } => {
emit_expr(b, expr, reg, None);
reg += 1;
}
ResultColumn::Star | ResultColumn::TableStar(_) => {
return Err(CodegenError::Unsupported(
"INSERT ... SELECT without FROM does not support `*` projections".to_owned(),
));
}
}
}
Ok(())
}
/// Emit RETURNING clause opcodes for a row-producing DML statement.
///
/// Positions the cursor on the just-inserted row via `SeekRowid`, reads the
/// requested columns, and emits a `ResultRow`.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_returning(
b: &mut ProgramBuilder,
cursor: i32,
table: &TableSchema,
returning: &[ResultColumn],
table_alias: Option<&str>,
rowid_reg: i32,
) -> Result<(), CodegenError> {
// RETURNING is validated against the DML target, which in these single-table
// codegen paths is always a main (or implicit) binding: attached-schema DML
// is delegated or rejected upstream, so a `None` (implicit `main`) FROM
// schema is the correct reconciliation base here.
validate_single_table_result_columns(returning, table, table_alias, None)?;
let skip_returning = b.emit_label();
b.emit_jump_to_label(
Opcode::SeekRowid,
cursor,
rowid_reg,
skip_returning,
P4::None,
0,
);
let ret_count = result_column_count(returning, table);
let ret_regs = b.alloc_regs(ret_count);
emit_column_reads(b, cursor, returning, table, table_alias, &[], ret_regs)?;
b.emit_op(Opcode::ResultRow, ret_regs, ret_count, 0, P4::None, 0);
b.resolve_label(skip_returning);
Ok(())
}
/// Emit a RETURNING clause for a WITHOUT ROWID DML row whose full column image
/// already lives in `col_regs` (column `i` at `col_regs + i`). Unlike
/// [`emit_returning`], there is no rowid to re-seek by, so column references and
/// expressions are evaluated directly against the in-register row image via
/// `ScanCtx::register_base`. Used for INSERT (inserted image), UPDATE (new
/// image), and DELETE (deleted image).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_returning_from_regs(
b: &mut ProgramBuilder,
table: &TableSchema,
returning: &[ResultColumn],
table_alias: Option<&str>,
schema: &[TableSchema],
col_regs: i32,
) -> Result<(), CodegenError> {
// RETURNING is validated against the DML target, which in these single-table
// codegen paths is always a main (or implicit) binding: attached-schema DML
// is delegated or rejected upstream, so a `None` (implicit `main`) FROM
// schema is the correct reconciliation base here.
validate_single_table_result_columns(returning, table, table_alias, None)?;
let ret_count = result_column_count(returning, table);
let ret_regs = b.alloc_regs(ret_count);
let mut reg = ret_regs;
for col in returning {
match col {
ResultColumn::Star => {
for i in 0..table.columns.len() {
b.emit_op(Opcode::Copy, col_regs + i as i32, reg, 0, P4::None, 0);
reg += 1;
}
}
ResultColumn::TableStar(qualifier) => {
if !matches_table_or_alias(&qualifier.name, table, table_alias) {
return Err(CodegenError::TableNotFound(qualifier.to_string()));
}
for i in 0..table.columns.len() {
b.emit_op(Opcode::Copy, col_regs + i as i32, reg, 0, P4::None, 0);
reg += 1;
}
}
ResultColumn::Expr { expr, .. } => {
let scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: Some(col_regs),
secondaries: &[],
};
emit_expr(b, expr, reg, Some(&scan));
reg += 1;
}
}
}
b.emit_op(Opcode::ResultRow, ret_regs, ret_count, 0, P4::None, 0);
Ok(())
}
/// Emit a HAVING filter for GROUP BY queries.
///
/// Evaluates the HAVING expression against the already-built output row.
/// Aggregate function calls and column references are resolved to the
/// corresponding output registers. If the predicate is false, jumps to
/// `skip_label` (skipping the `ResultRow`).
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments
)]
fn emit_having_filter(
b: &mut ProgramBuilder,
having_expr: &Expr,
output_cols: &[GroupByOutputCol],
agg_columns: &[AggColumn],
group_by_keys: &[GroupByKey],
table: &TableSchema,
out_regs: i32,
skip_label: crate::Label,
) {
let result_reg = b.alloc_temp();
emit_having_expr(
b,
having_expr,
result_reg,
output_cols,
agg_columns,
group_by_keys,
table,
out_regs,
);
// If result is falsy (0 or NULL), skip this group's ResultRow.
// p3=1: NULL HAVING → jump (skip row), matching SQLite semantics.
b.emit_jump_to_label(Opcode::IfNot, result_reg, 1, skip_label, P4::None, 0);
b.free_temp(result_reg);
}
/// Evaluate a HAVING expression into `dest_reg`.
///
/// Maps aggregate function calls and column references to the output
/// registers that already hold the finalized group results.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_arguments,
clippy::too_many_lines
)]
fn emit_having_expr(
b: &mut ProgramBuilder,
expr: &Expr,
dest_reg: i32,
output_cols: &[GroupByOutputCol],
agg_columns: &[AggColumn],
group_by_keys: &[GroupByKey],
table: &TableSchema,
out_regs: i32,
) {
match expr {
// Aggregate function call — resolve to the corresponding output register.
Expr::FunctionCall { name, args, .. } if is_aggregate_function_call(name, args) => {
let upper = name.to_ascii_uppercase();
// Find the matching aggregate by name + argument structure.
let agg_idx = agg_columns.iter().position(|agg| {
if agg.name != upper {
return false;
}
match args {
FunctionArgs::Star => agg.num_args == 0,
FunctionArgs::List(exprs) => {
if exprs.is_empty() {
return agg.num_args == 0;
}
// Match by argument column index first.
if let Some(ci) = resolve_column_index(&exprs[0], table) {
agg.arg_col_index == Some(ci)
} else if let Some(ref arg_expr) = agg.arg_expr {
// Fall back to structural expression comparison
// for aggregates with expression arguments.
exprs.len() == 1 && **arg_expr == exprs[0]
} else {
false
}
}
}
});
if let Some(ai) = agg_idx {
// Find the output register for this aggregate.
for (i, oc) in output_cols.iter().enumerate() {
if matches!(oc, GroupByOutputCol::Aggregate { agg_index } if *agg_index == ai) {
b.emit_op(Opcode::Copy, out_regs + i as i32, dest_reg, 0, P4::None, 0);
return;
}
}
}
// Fallback: treat as zero (unknown aggregate in HAVING).
b.emit_op(Opcode::Integer, 0, dest_reg, 0, P4::None, 0);
}
// Column reference — resolve to the corresponding group-key output register.
Expr::Column(col_ref, _) => {
let col_name = &col_ref.column;
if let Some(col_idx) = table
.columns
.iter()
.position(|c| c.name == col_name.as_ref())
{
// Find the output column whose group key maps to this table column.
for (i, oc) in output_cols.iter().enumerate() {
if let GroupByOutputCol::GroupKey { key_index, .. } = oc
&& matches!(group_by_keys.get(*key_index), Some(GroupByKey::Column(c)) if *c == col_idx)
{
b.emit_op(Opcode::Copy, out_regs + i as i32, dest_reg, 0, P4::None, 0);
return;
}
}
// GH #225: a bare column referenced by HAVING (no GROUP BY, or an
// unprojected column) is captured as a hidden bare_expr aggregate
// holding the first scanned row's value. Resolve it to that
// aggregate's output register instead of NULL.
if let Some(agg_i) = agg_columns.iter().position(|agg| {
agg.bare_expr
.as_deref()
.and_then(|be| resolve_column_index(be, table))
== Some(col_idx)
}) {
for (i, oc) in output_cols.iter().enumerate() {
if matches!(oc, GroupByOutputCol::Aggregate { agg_index } if *agg_index == agg_i)
{
b.emit_op(Opcode::Copy, out_regs + i as i32, dest_reg, 0, P4::None, 0);
return;
}
}
}
}
// Fallback: emit NULL for unresolved column.
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
}
// Binary comparison — evaluate both sides, then compare.
Expr::BinaryOp {
left, op, right, ..
} => {
let left_reg = b.alloc_temp();
let right_reg = b.alloc_temp();
emit_having_expr(
b,
left,
left_reg,
output_cols,
agg_columns,
group_by_keys,
table,
out_regs,
);
emit_having_expr(
b,
right,
right_reg,
output_cols,
agg_columns,
group_by_keys,
table,
out_regs,
);
match op {
fsqlite_ast::BinaryOp::Gt
| fsqlite_ast::BinaryOp::Lt
| fsqlite_ast::BinaryOp::Ge
| fsqlite_ast::BinaryOp::Le
| fsqlite_ast::BinaryOp::Eq
| fsqlite_ast::BinaryOp::Ne => {
let cmp_opcode = match op {
fsqlite_ast::BinaryOp::Gt => Opcode::Gt,
fsqlite_ast::BinaryOp::Lt => Opcode::Lt,
fsqlite_ast::BinaryOp::Ge => Opcode::Ge,
fsqlite_ast::BinaryOp::Le => Opcode::Le,
fsqlite_ast::BinaryOp::Eq => Opcode::Eq,
fsqlite_ast::BinaryOp::Ne => Opcode::Ne,
_ => unreachable!(),
};
// SQL three-valued logic: if either operand is NULL, result is NULL.
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, left_reg, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, right_reg, 0, null_label, P4::None, 0);
b.emit_jump_to_label(cmp_opcode, right_reg, left_reg, true_label, P4::None, 0);
b.emit_op(Opcode::Integer, 0, dest_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, dest_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, dest_reg, 0, P4::None, 0);
b.resolve_label(done_label);
}
fsqlite_ast::BinaryOp::And => {
b.emit_op(Opcode::And, left_reg, right_reg, dest_reg, P4::None, 0);
}
fsqlite_ast::BinaryOp::Or => {
b.emit_op(Opcode::Or, left_reg, right_reg, dest_reg, P4::None, 0);
}
_ => {
emit_expr(b, expr, dest_reg, None);
}
}
b.free_temp(right_reg);
b.free_temp(left_reg);
}
// GH #225: `x IS [NOT] NULL` in HAVING must evaluate its operand through
// the HAVING resolver (so a bare column resolves to its captured first-row
// aggregate register) rather than the raw evaluator, which would leave the
// column NULL and wrongly satisfy `IS NULL`.
Expr::IsNull {
expr: inner, not, ..
} => {
let val_reg = b.alloc_temp();
emit_having_expr(
b,
inner,
val_reg,
output_cols,
agg_columns,
group_by_keys,
table,
out_regs,
);
let is_null_lbl = b.emit_label();
let done_lbl = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, val_reg, 0, is_null_lbl, P4::None, 0);
// Operand is non-NULL: `IS NULL` -> 0, `IS NOT NULL` -> 1.
b.emit_op(Opcode::Integer, i32::from(*not), dest_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_lbl, P4::None, 0);
b.resolve_label(is_null_lbl);
// Operand is NULL: `IS NULL` -> 1, `IS NOT NULL` -> 0.
b.emit_op(Opcode::Integer, i32::from(!*not), dest_reg, 0, P4::None, 0);
b.resolve_label(done_lbl);
b.free_temp(val_reg);
}
// For any other expression, delegate to the standard evaluator.
_ => {
emit_expr(b, expr, dest_reg, None);
}
}
}
/// Emit a WHERE filter for scan-based UPDATE/DELETE.
///
/// Evaluates the WHERE expression against the current cursor row. If the
/// condition is false, jumps to `skip_label` (skipping the DML operation).
///
/// Handles `col = expr` comparisons by reading the column from the cursor
/// and comparing with the literal/expression value.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_where_filter(
b: &mut ProgramBuilder,
where_expr: &Expr,
cursor: i32,
table: &TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
skip_label: crate::Label,
) {
let scan = ScanCtx {
cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_where_filter_with_ctx(b, where_expr, &scan, skip_label);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_where_filter_with_ctx(
b: &mut ProgramBuilder,
where_expr: &Expr,
scan: &ScanCtx<'_>,
skip_label: crate::Label,
) {
match where_expr {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} => {
let comparison = ResolvedComparisonInfo::new(left, right, scan);
if let Some(resolved) = comparison.left_resolved.as_ref() {
let col_reg = b.alloc_temp();
let val_reg = b.alloc_temp();
emit_resolved_column(b, resolved, scan.cursor, col_reg, scan);
emit_expr(b, right, val_reg, Some(scan));
// SQL semantics: `col = NULL` is UNKNOWN (false in WHERE). If the
// value expression evaluates to NULL, skip the row unconditionally.
b.emit_jump_to_label(Opcode::IsNull, val_reg, 0, skip_label, P4::None, 0);
// NULLEQ (0x80) | comparison affinity so the engine coerces correctly.
b.emit_jump_to_label(
Opcode::Ne,
val_reg,
col_reg,
skip_label,
comparison.collation_p4.clone(),
comparison.cmp_p5,
);
b.free_temp(val_reg);
b.free_temp(col_reg);
} else if let Some(resolved) = comparison.right_resolved.as_ref() {
let col_reg = b.alloc_temp();
let val_reg = b.alloc_temp();
emit_resolved_column(b, resolved, scan.cursor, col_reg, scan);
emit_expr(b, left, val_reg, Some(scan));
// SQL semantics: `NULL = col` is UNKNOWN (false in WHERE).
b.emit_jump_to_label(Opcode::IsNull, val_reg, 0, skip_label, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
val_reg,
col_reg,
skip_label,
comparison.collation_p4.clone(),
comparison.cmp_p5,
);
b.free_temp(val_reg);
b.free_temp(col_reg);
} else {
// Neither side is a column ref (e.g. WHERE 1 = 0, WHERE length(name) = 5).
// Fall through to generic boolean evaluation.
let cond_reg = b.alloc_temp();
emit_expr(b, where_expr, cond_reg, Some(scan));
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
b.free_temp(cond_reg);
}
}
// Inequality comparisons: Ne, Lt, Le, Gt, Ge.
// Same structure as Eq but with the appropriate skip opcode and
// operand order so that column affinity p5 flags are applied.
Expr::BinaryOp {
left,
op:
op @ (fsqlite_ast::BinaryOp::Ne
| fsqlite_ast::BinaryOp::Lt
| fsqlite_ast::BinaryOp::Le
| fsqlite_ast::BinaryOp::Gt
| fsqlite_ast::BinaryOp::Ge),
right,
..
} => {
let comparison = ResolvedComparisonInfo::new(left, right, scan);
// Determine the skip opcode — the inverse of the comparison.
// If `col > val` is the condition, skip when `col <= val`, i.e. Le.
let skip_opcode = match op {
fsqlite_ast::BinaryOp::Ne => Opcode::Eq,
fsqlite_ast::BinaryOp::Lt => Opcode::Ge,
fsqlite_ast::BinaryOp::Le => Opcode::Gt,
fsqlite_ast::BinaryOp::Gt => Opcode::Le,
fsqlite_ast::BinaryOp::Ge => Opcode::Lt,
_ => unreachable!(),
};
if let Some(resolved) = comparison.left_resolved.as_ref() {
let col_reg = b.alloc_temp();
let val_reg = b.alloc_temp();
emit_resolved_column(b, resolved, scan.cursor, col_reg, scan);
emit_expr(b, right, val_reg, Some(scan));
b.emit_jump_to_label(Opcode::IsNull, val_reg, 0, skip_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, col_reg, 0, skip_label, P4::None, 0);
b.emit_jump_to_label(
skip_opcode,
val_reg,
col_reg,
skip_label,
comparison.collation_p4.clone(),
comparison.cmp_p5,
);
b.free_temp(val_reg);
b.free_temp(col_reg);
} else if let Some(resolved) = comparison.right_resolved.as_ref() {
// col is on the right: `val op col` → swap operand order.
// `val < col` skip when `val >= col`, i.e. Ge(val, col).
// The skip opcode is the inverse of `val op col`.
let swapped_skip = match op {
fsqlite_ast::BinaryOp::Ne => Opcode::Eq,
fsqlite_ast::BinaryOp::Lt => Opcode::Ge,
fsqlite_ast::BinaryOp::Le => Opcode::Gt,
fsqlite_ast::BinaryOp::Gt => Opcode::Le,
fsqlite_ast::BinaryOp::Ge => Opcode::Lt,
_ => unreachable!(),
};
let col_reg = b.alloc_temp();
let val_reg = b.alloc_temp();
emit_resolved_column(b, resolved, scan.cursor, col_reg, scan);
emit_expr(b, left, val_reg, Some(scan));
b.emit_jump_to_label(Opcode::IsNull, val_reg, 0, skip_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, col_reg, 0, skip_label, P4::None, 0);
// VDBE comparison: opcode P1=rhs P2=lhs. For `val < col` skip
// when NOT(val < col), i.e. val >= col → Ge(val_reg, col_reg).
b.emit_jump_to_label(
swapped_skip,
col_reg,
val_reg,
skip_label,
comparison.collation_p4.clone(),
comparison.cmp_p5,
);
b.free_temp(val_reg);
b.free_temp(col_reg);
} else {
let cond_reg = b.alloc_temp();
emit_expr(b, where_expr, cond_reg, Some(scan));
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
b.free_temp(cond_reg);
}
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
// AND: both conditions must pass.
emit_where_filter_with_ctx(b, left, scan, skip_label);
emit_where_filter_with_ctx(b, right, scan, skip_label);
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Or,
right,
..
} => {
// OR: at least one condition must pass.
// If left passes → skip right, proceed to row processing.
// If left fails → try right; if right also fails → skip row.
let left_skip = b.emit_label();
let pass_label = b.emit_label();
emit_where_filter_with_ctx(b, left, scan, left_skip);
// Left passed — jump past right-side evaluation.
b.emit_jump_to_label(Opcode::Goto, 0, 0, pass_label, P4::None, 0);
b.resolve_label(left_skip);
// Left failed — try right.
emit_where_filter_with_ctx(b, right, scan, skip_label);
b.resolve_label(pass_label);
}
_ => {
// Generic WHERE: evaluate expression with cursor context and test truthiness.
let cond_reg = b.alloc_temp();
emit_expr(b, where_expr, cond_reg, Some(scan));
b.emit_jump_to_label(Opcode::IfNot, cond_reg, 1, skip_label, P4::None, 0);
b.free_temp(cond_reg);
}
}
}
struct ResolvedComparisonInfo {
left_resolved: Option<SortKeySource>,
right_resolved: Option<SortKeySource>,
collation_p4: P4,
cmp_p5: u16,
}
impl ResolvedComparisonInfo {
fn new(left: &Expr, right: &Expr, scan: &ScanCtx<'_>) -> Self {
let left_resolved = resolve_column_ref(left, scan.table, scan.table_alias);
let right_resolved = resolve_column_ref(right, scan.table, scan.table_alias);
let collation_p4 = extract_collation(left)
.or_else(|| extract_collation(right))
.or_else(|| bound_outer_declared_collation(left))
.or_else(|| resolved_primary_collation(left_resolved.as_ref(), scan.table))
.or_else(|| bound_outer_declared_collation(right))
.or_else(|| resolved_primary_collation(right_resolved.as_ref(), scan.table))
.map_or(P4::None, |coll| P4::Collation(coll.to_owned()));
let cmp_p5 = 0x80
| comparison_affinity_p5_resolved(
left,
left_resolved.as_ref(),
right,
right_resolved.as_ref(),
scan,
);
Self {
left_resolved,
right_resolved,
collation_p4,
cmp_p5,
}
}
}
fn resolved_primary_collation<'a>(
resolved: Option<&SortKeySource>,
table: &'a TableSchema,
) -> Option<&'a str> {
match resolved {
Some(SortKeySource::Column(idx)) => table.columns.get(*idx)?.collation.as_deref(),
Some(SortKeySource::Rowid | SortKeySource::Expression(_)) | None => None,
}
}
fn resolved_expr_affinity(expr: &Expr, resolved: Option<&SortKeySource>, scan: &ScanCtx<'_>) -> u8 {
match resolved {
Some(SortKeySource::Column(idx)) => scan
.table
.columns
.get(*idx)
.map_or(b'A', schema_column_expr_affinity),
Some(SortKeySource::Rowid) => b'D',
Some(SortKeySource::Expression(_)) | None => expr_affinity(expr, Some(scan)),
}
}
/// Core comparison-affinity rule (SQLite datatype3 §4.2) shared by every
/// comparison emit path: given the affinity codes (`A`=BLOB, `B`=TEXT,
/// `C`=NUMERIC, `D`=INTEGER, `E`=REAL) of the two operands, return the `p5`
/// affinity to apply — `0` for none, `b'B'` for TEXT, `b'C'` for NUMERIC.
fn combine_comparison_affinity(l_aff: u8, r_aff: u8) -> u16 {
let is_numeric = |a: u8| matches!(a, b'C' | b'D' | b'E');
// One side numeric (C/D/E), the other TEXT (B) or BLOB (A) → coerce the
// text/blob side to a number (NUMERIC affinity).
if (is_numeric(l_aff) && matches!(r_aff, b'A' | b'B'))
|| (is_numeric(r_aff) && matches!(l_aff, b'A' | b'B'))
{
return u16::from(b'C'); // NUMERIC
}
// One side TEXT (B), the other BLOB/NONE (A) → coerce the blob side to text.
if (l_aff == b'B' && r_aff == b'A') || (l_aff == b'A' && r_aff == b'B') {
return u16::from(b'B'); // TEXT
}
0 // No affinity coercion needed
}
fn comparison_affinity_p5_resolved(
left: &Expr,
left_resolved: Option<&SortKeySource>,
right: &Expr,
right_resolved: Option<&SortKeySource>,
scan: &ScanCtx<'_>,
) -> u16 {
combine_comparison_affinity(
resolved_expr_affinity(left, left_resolved, scan),
resolved_expr_affinity(right, right_resolved, scan),
)
}
/// Check whether a column name is a hidden rowid alias (`rowid`, `_rowid_`, or `oid`).
fn is_hidden_rowid_alias_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower == "rowid" || lower == "_rowid_" || lower == "oid"
}
/// Normalize an explicit `main` schema qualifier to the implicit (`None`) form.
///
/// SQLite treats the `main` database name as equivalent to an unqualified
/// reference, so `main.t` and a bare `t` name the same object. This mirrors
/// `connection.rs::normalized_attached_schema_name` so codegen reconciles a
/// schema-qualified result-column star against a bare FROM binding without
/// collapsing a genuine cross-database qualifier (e.g. `aux`).
fn normalized_schema_qualifier(schema: Option<&str>) -> Option<&str> {
match schema {
Some(name) if name.eq_ignore_ascii_case("main") => None,
other => other,
}
}
/// Decide whether a `qualifier.*` table-star names the single FROM/DML binding.
///
/// `from_schema` is the schema qualifier written on the binding itself
/// (`None` for a bare `t`, `Some("main")` for `main.t`, `Some("aux")` for an
/// attached `aux.t`). An aliased binding is addressable only by its alias, with
/// no schema qualifier. Otherwise the star's database and the binding's database
/// must agree after `main`/implicit normalization, so `main.t.*` matches a bare
/// `FROM t` while `aux.t.*` does not.
fn table_star_qualifier_matches_binding(
qualifier: &QualifiedName,
table: &TableSchema,
table_alias: Option<&str>,
from_schema: Option<&str>,
) -> bool {
if table_alias.is_some() {
return qualifier.schema.is_none()
&& matches_table_or_alias(&qualifier.name, table, table_alias);
}
normalized_schema_qualifier(qualifier.schema.as_deref())
== normalized_schema_qualifier(from_schema)
&& matches_table_or_alias(&qualifier.name, table, table_alias)
}
fn matches_table_or_alias(qualifier: &str, table: &TableSchema, table_alias: Option<&str>) -> bool {
table_alias.map_or_else(
|| qualifier.eq_ignore_ascii_case(&table.name),
|alias| qualifier.eq_ignore_ascii_case(alias),
)
}
fn qualified_column_not_found(qualifier: &str, column: &str) -> CodegenError {
// SQLite reports bad qualified references as "no such column: q.c",
// including cases where `q` is a hidden base table name after aliasing.
// Keep the qualified spelling in `column` so the public error preserves it.
CodegenError::ColumnNotFound {
table: qualifier.to_owned(),
column: format!("{qualifier}.{column}"),
}
}
/// Source for a sort key: either a table column or the implicit rowid.
#[derive(Clone)]
enum SortKeySource {
Column(usize),
Rowid,
/// Arbitrary expression (e.g., `a + b`, `LENGTH(name)`, `CASE WHEN ...`).
Expression(Box<Expr>),
}
/// Emit bytecode to load a resolved column reference into a register.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_resolved_column(
b: &mut ProgramBuilder,
resolved: &SortKeySource,
cursor: i32,
reg: i32,
scan: &ScanCtx<'_>,
) {
match resolved {
SortKeySource::Column(idx) => {
// bd-r3303: when reading from the scanned table's own cursor, route
// through emit_table_column_read so a VIRTUAL generated column is
// computed on read. For other cursors (e.g. a sorter/join feed where
// base columns are already materialized) keep the direct read.
if cursor == scan.cursor && *idx < scan.table.columns.len() {
emit_table_column_read(
b,
cursor,
scan.table,
scan.table_alias,
scan.schema,
*idx,
reg,
);
} else {
b.emit_op(Opcode::Column, cursor, *idx as i32, reg, P4::None, 0);
}
}
SortKeySource::Rowid => {
b.emit_op(Opcode::Rowid, cursor, reg, 0, P4::None, 0);
}
SortKeySource::Expression(expr) => {
emit_expr(b, expr, reg, Some(scan));
}
}
}
/// Output source for a covering-index ordered scan.
enum CoveringOutputSource {
/// Read value from index key column at this position.
IndexColumn(i32),
/// Read value from the rowid already extracted via `IdxRowid`.
Rowid,
}
/// Plan for ORDER BY execution that can bypass the sorter.
struct OrderByIndexPlan {
index: IndexSchema,
descending: bool,
equality_prefix_len: usize,
/// When present, all output columns can be read from index payload/rowid
/// and no table row lookup is required.
covering_output: Option<Vec<CoveringOutputSource>>,
}
/// Resolve an ORDER BY expression to a `SortKeySource`.
///
/// Returns `Column` or `Rowid` for simple column references; falls back to
/// `Expression` for arbitrary expressions (arithmetic, function calls, etc.).
/// Handles numeric column indices (e.g., `ORDER BY 2`) by resolving them to
/// the corresponding result column expression.
fn resolve_sort_key(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
columns: &[ResultColumn],
) -> SortKeySource {
if let Some(output_expr) = resolve_order_by_output_expr(expr, columns) {
return resolve_sort_key(output_expr, table, table_alias, columns);
}
if let Expr::Column(col_ref, _) = expr {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return SortKeySource::Expression(Box::new(expr.clone()));
}
if let Some(idx) = table.column_index(&col_ref.column) {
return SortKeySource::Column(idx);
}
if table.resolves_to_hidden_rowid(&col_ref.column) {
return SortKeySource::Rowid;
}
}
SortKeySource::Expression(Box::new(expr.clone()))
}
fn resolve_order_by_output_expr<'a>(
expr: &'a Expr,
columns: &'a [ResultColumn],
) -> Option<&'a Expr> {
if let Some(ordinal) = order_by_integer_ordinal(expr) {
let idx = usize::try_from(ordinal).ok()?;
if idx == 0 || idx > columns.len() {
return None;
}
return match &columns[idx - 1] {
ResultColumn::Expr {
expr: output_expr, ..
} => Some(output_expr),
ResultColumn::Star | ResultColumn::TableStar(_) => None,
};
}
// COLLATE changes ordering semantics but not output-alias name
// resolution. Preserve the wrapper for sorter collation metadata at the
// caller while resolving its inner expression against the SELECT list.
let mut expr = expr;
while let Expr::Collate { expr: inner, .. } = expr {
expr = inner.as_ref();
}
let Expr::Column(col_ref, _) = expr else {
return None;
};
if col_ref.table.is_some() {
return None;
}
columns.iter().find_map(|column| {
let ResultColumn::Expr {
alias: Some(alias),
expr: output_expr,
} = column
else {
return None;
};
if !alias.eq_ignore_ascii_case(&col_ref.column) {
return None;
}
match output_expr {
Expr::Column(output_col_ref, _)
if output_col_ref.table.is_none()
&& output_col_ref.column.eq_ignore_ascii_case(&col_ref.column) =>
{
None
}
_ => Some(output_expr),
}
})
}
/// Resolve an exact ORDER BY output reference to its flattened result slot.
///
/// Unlike `resolve_order_by_output_expr`, ordinals count columns expanded from
/// `*`, because sorter records store the flattened result shape. Alias and
/// structural-expression matches follow the same COLLATE distinction as
/// `resolve_single_output_order_expr`: COLLATE may wrap an alias/ordinal, but a
/// separately written collated expression remains a separate evaluation.
fn resolve_order_by_output_slot(
order_expr: &Expr,
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<usize> {
if let Some(ordinal) = order_by_integer_ordinal(order_expr) {
let one_based = usize::try_from(ordinal).ok()?;
let slot = one_based.checked_sub(1)?;
return (slot < result_column_count_usize(columns, table)).then_some(slot);
}
let mut alias_expr = order_expr;
while let Expr::Collate { expr: inner, .. } = alias_expr {
alias_expr = inner.as_ref();
}
if let Expr::Column(column, _) = alias_expr
&& column.table.is_none()
{
let mut slot = 0usize;
for result_column in columns {
match result_column {
ResultColumn::Star | ResultColumn::TableStar(_) => {
slot += table.columns.len();
}
ResultColumn::Expr {
expr: output_expr,
alias: Some(alias),
} if alias.eq_ignore_ascii_case(&column.column) => {
let is_same_named_source_column = matches!(
output_expr,
Expr::Column(output_column, _)
if output_column.table.is_none()
&& output_column
.column
.eq_ignore_ascii_case(&column.column)
);
if !is_same_named_source_column {
return Some(slot);
}
slot += 1;
}
ResultColumn::Expr { .. } => slot += 1,
}
}
}
let mut slot = 0usize;
for result_column in columns {
match result_column {
ResultColumn::Star | ResultColumn::TableStar(_) => {
slot += table.columns.len();
}
ResultColumn::Expr {
expr: output_expr, ..
} => {
if expressions_match_table_locally(order_expr, output_expr, table, table_alias) {
return Some(slot);
}
slot += 1;
}
}
}
None
}
fn result_output_slot_collation(
output_slot: usize,
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<String> {
let mut slot = 0usize;
for result_column in columns {
match result_column {
ResultColumn::Star | ResultColumn::TableStar(_) => {
let relative = output_slot.checked_sub(slot)?;
if relative < table.columns.len() {
return table.columns[relative].collation.clone();
}
slot += table.columns.len();
}
ResultColumn::Expr { expr, .. } => {
if slot == output_slot {
return extract_collation(expr)
.or_else(|| column_collation(expr, table, table_alias))
.map(ToOwned::to_owned);
}
slot += 1;
}
}
}
None
}
/// How an ordered DISTINCT query produces its final result row.
///
/// SQLite has two observably different plans. A general ordered DISTINCT
/// query retains the first projected output for each DISTINCT key and emits
/// that stored value after sorting. Plain exact ascending output tuples may
/// retain only their representative source row and project it after sorting.
/// Function-bearing projections retain their stored result so a volatile
/// function is evaluated exactly once per source row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrderedDistinctProjectionMode {
StoredOutput,
ReprojectRepresentative,
}
fn ordered_distinct_projection_mode(
distinct: Distinctness,
order_by: &[OrderingTerm],
order_output_slots: &[Option<usize>],
sort_collations: &[String],
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> OrderedDistinctProjectionMode {
let output_count = result_column_count_usize(columns, table);
if distinct != Distinctness::Distinct
|| order_by.len() != output_count
|| order_output_slots.len() != output_count
|| sort_collations.len() != output_count
|| columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. } if expr_contains_function_call(expr)
)
})
{
return OrderedDistinctProjectionMode::StoredOutput;
}
for (slot, term) in order_by.iter().enumerate() {
if order_output_slots[slot] != Some(slot)
|| term.direction == Some(SortDirection::Desc)
|| term.nulls == Some(NullsOrder::Last)
// An explicit COLLATE in the ORDER expression forces SQLite's
// separate DISTINCT-membership/order plan, even when it names the
// same collation as the output expression.
|| extract_collation(&term.expr).is_some()
{
return OrderedDistinctProjectionMode::StoredOutput;
}
let output_collation = result_output_slot_collation(slot, columns, table, table_alias);
let order_collation =
(!sort_collations[slot].is_empty()).then_some(sort_collations[slot].as_str());
if !collation_names_equivalent(output_collation.as_deref(), order_collation) {
return OrderedDistinctProjectionMode::StoredOutput;
}
}
OrderedDistinctProjectionMode::ReprojectRepresentative
}
/// Resolve an ORDER BY integer ordinal, including SQLite's signed-literal
/// forms (`+1` is ordinal 1 and `-1` is an out-of-range ordinal).
///
/// COLLATE wrappers do not turn an ordinal into a constant expression. The
/// checked negation keeps the pathological `-i64::MIN` AST fail-closed.
fn order_by_integer_ordinal(expr: &Expr) -> Option<i64> {
match expr {
Expr::Literal(Literal::Integer(value), _) => Some(*value),
Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Plus,
expr,
..
}
| Expr::Collate { expr, .. } => order_by_integer_ordinal(expr),
Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Negate,
expr,
..
} => order_by_integer_ordinal(expr)?.checked_neg(),
_ => None,
}
}
fn order_by_refers_to_single_star_output(expr: &Expr, columns: &[ResultColumn]) -> bool {
if !matches!(columns, [ResultColumn::Star | ResultColumn::TableStar(_)]) {
return false;
}
order_by_integer_ordinal(expr) == Some(1)
}
/// Return the sole projected expression when an ORDER BY term names it
/// directly (alias/ordinal) or is structurally the same expression.
///
/// SQLite evaluates a volatile expression only once per source row for these
/// exact non-DISTINCT ORDER BY references. COLLATE may wrap an alias/ordinal
/// without preventing name resolution, but otherwise remains part of the
/// structural expression. Local column qualifiers, identifier case, hidden
/// rowid synonyms, and an INTEGER PRIMARY KEY alias are schema-normalized
/// before the structural comparison.
fn resolve_single_output_order_expr<'a>(
order_expr: &'a Expr,
columns: &'a [ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<&'a Expr> {
if let Some(output_expr) = resolve_order_by_output_expr(order_expr, columns) {
return Some(output_expr);
}
let [
ResultColumn::Expr {
expr: output_expr, ..
},
] = columns
else {
return None;
};
// Do not strip COLLATE for structural matching. `ORDER BY alias COLLATE`
// was already resolved above and reuses the output, but a separately
// written `ORDER BY volatile_expr() COLLATE ...` is a distinct expression
// that SQLite evaluates again.
expressions_match_table_locally(order_expr, output_expr, table, table_alias)
.then_some(output_expr)
}
/// Resolve a column reference expression to either a column index or rowid.
///
/// Validates that any table qualifier matches the table name or alias.
/// Returns `None` if the expression is not a column reference or the
/// qualifier does not match.
fn resolve_column_ref(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<SortKeySource> {
// Unwrap COLLATE wrapper to reach the inner column reference.
let inner = if let Expr::Collate { expr: inner, .. } = expr {
inner.as_ref()
} else {
expr
};
if let Expr::Column(col_ref, _) = inner {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return None;
}
if let Some(idx) = table.column_index(&col_ref.column) {
// INTEGER PRIMARY KEY columns are stored as rowid, not in the record payload.
// Return Rowid so callers emit the Rowid opcode instead of Column.
if table.columns[idx].is_ipk {
return Some(SortKeySource::Rowid);
}
return Some(SortKeySource::Column(idx));
}
if table.resolves_to_hidden_rowid(&col_ref.column) {
return Some(SortKeySource::Rowid);
}
}
None
}
fn validate_single_table_expr_columns(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Result<(), CodegenError> {
validate_expr_columns_with(expr, &|col_ref| {
validate_single_table_column_ref(col_ref, table, table_alias)
})
}
fn validate_scan_expr_columns(expr: &Expr, scan: &ScanCtx<'_>) -> Result<(), CodegenError> {
validate_expr_columns_with(expr, &|col_ref| validate_scan_column_ref(col_ref, scan))
}
fn validate_upsert_expr_columns(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Result<(), CodegenError> {
validate_expr_columns_with(expr, &|col_ref| {
validate_upsert_column_ref(col_ref, table, table_alias)
})
}
fn validate_assignment_target(
table: &TableSchema,
target: &AssignmentTarget,
) -> Result<(), CodegenError> {
match target {
AssignmentTarget::Column(name) => {
if table.column_index(name).is_some() {
Ok(())
} else {
Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.clone(),
})
}
}
AssignmentTarget::ColumnList(columns) => {
if columns.is_empty() {
return Err(CodegenError::Unsupported(
"multi-column SET requires at least one target column".to_owned(),
));
}
for name in columns {
if table.column_index(name).is_none() {
return Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: name.clone(),
});
}
}
Ok(())
}
}
}
fn validate_expr_columns_with<F>(expr: &Expr, validate_column: &F) -> Result<(), CodegenError>
where
F: Fn(&ColumnRef) -> Result<(), CodegenError>,
{
match expr {
Expr::Column(col_ref, _) => validate_column(col_ref),
Expr::BinaryOp { left, right, .. } => {
validate_expr_columns_with(left, validate_column)?;
validate_expr_columns_with(right, validate_column)
}
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => validate_expr_columns_with(expr, validate_column),
Expr::Between {
expr, low, high, ..
} => {
validate_expr_columns_with(expr, validate_column)?;
validate_expr_columns_with(low, validate_column)?;
validate_expr_columns_with(high, validate_column)
}
Expr::In { expr, set, .. } => {
validate_expr_columns_with(expr, validate_column)?;
if let InSet::List(values) = set {
for value in values {
validate_expr_columns_with(value, validate_column)?;
}
}
Ok(())
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
validate_expr_columns_with(expr, validate_column)?;
validate_expr_columns_with(pattern, validate_column)?;
if let Some(escape) = escape {
validate_expr_columns_with(escape, validate_column)?;
}
Ok(())
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
if let Some(operand) = operand {
validate_expr_columns_with(operand, validate_column)?;
}
for (when_expr, then_expr) in whens {
validate_expr_columns_with(when_expr, validate_column)?;
validate_expr_columns_with(then_expr, validate_column)?;
}
if let Some(else_expr) = else_expr {
validate_expr_columns_with(else_expr, validate_column)?;
}
Ok(())
}
Expr::FunctionCall {
args,
order_by,
filter,
..
} => {
if let FunctionArgs::List(arg_exprs) = args {
for arg_expr in arg_exprs {
validate_expr_columns_with(arg_expr, validate_column)?;
}
}
for term in order_by {
validate_expr_columns_with(&term.expr, validate_column)?;
}
if let Some(filter) = filter {
validate_expr_columns_with(filter, validate_column)?;
}
Ok(())
}
Expr::JsonAccess { expr, path, .. } => {
validate_expr_columns_with(expr, validate_column)?;
validate_expr_columns_with(path, validate_column)
}
Expr::RowValue(exprs, _) => {
for expr in exprs {
validate_expr_columns_with(expr, validate_column)?;
}
Ok(())
}
Expr::Exists { .. }
| Expr::Subquery(_, _)
| Expr::Literal(_, _)
| Expr::BoundOuterValue { .. }
| Expr::Placeholder(_, _)
| Expr::Raise { .. } => Ok(()),
}
}
fn validate_single_table_result_columns(
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
from_schema: Option<&str>,
) -> Result<(), CodegenError> {
for column in columns {
match column {
ResultColumn::Star => {}
ResultColumn::TableStar(qualifier) => {
if !table_star_qualifier_matches_binding(qualifier, table, table_alias, from_schema)
{
return Err(CodegenError::TableNotFound(qualifier.to_string()));
}
}
ResultColumn::Expr { expr, .. } => {
validate_single_table_expr_columns(expr, table, table_alias)?;
}
}
}
Ok(())
}
fn validate_single_table_order_by_terms(
order_by: &[OrderingTerm],
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
) -> Result<(), CodegenError> {
let output_count = result_column_count_usize(columns, table);
for (term_index, term) in order_by.iter().enumerate() {
if let Some(ordinal) = order_by_integer_ordinal(&term.expr) {
let in_range = usize::try_from(ordinal)
.is_ok_and(|one_based| (1..=output_count).contains(&one_based));
if !in_range {
return Err(CodegenError::Unsupported(format!(
"ORDER BY term {} out of range - should be between 1 and {output_count}",
term_index + 1
)));
}
}
let rewritten = rewrite_having_select_aliases(&term.expr, columns, table);
let expr = resolve_order_by_output_expr(&rewritten, columns).unwrap_or(&rewritten);
validate_single_table_expr_columns(expr, table, table_alias)?;
}
Ok(())
}
fn validate_single_table_column_ref(
col_ref: &ColumnRef,
table: &TableSchema,
table_alias: Option<&str>,
) -> Result<(), CodegenError> {
if let Some(qualifier) = col_ref.table.as_deref()
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
if table.column_index(&col_ref.column).is_some()
|| table.resolves_to_hidden_rowid(&col_ref.column)
{
return Ok(());
}
if let Some(qualifier) = col_ref.table.as_deref() {
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_ref.column.to_string(),
})
}
fn validate_scan_column_ref(col_ref: &ColumnRef, scan: &ScanCtx<'_>) -> Result<(), CodegenError> {
if let Some(qualifier) = col_ref.table.as_deref() {
if matches_table_or_alias(qualifier, scan.table, scan.table_alias) {
if table_has_column_or_rowid(scan.table, &col_ref.column) {
return Ok(());
}
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
if let Some(secondary) = scan.secondaries.iter().find(|secondary| {
matches_table_or_alias(qualifier, secondary.table, secondary.table_alias)
}) {
if table_has_column_or_rowid(secondary.table, &col_ref.column) {
return Ok(());
}
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
return Err(qualified_column_not_found(qualifier, &col_ref.column));
}
let mut match_count = usize::from(table_has_column_or_rowid(scan.table, &col_ref.column));
match_count += scan
.secondaries
.iter()
.filter(|secondary| table_has_column_or_rowid(secondary.table, &col_ref.column))
.count();
match match_count {
0 => Err(CodegenError::ColumnNotFound {
table: scan.table.name.clone(),
column: col_ref.column.to_string(),
}),
1 => Ok(()),
_ => Err(CodegenError::AmbiguousColumn(col_ref.column.to_string())),
}
}
fn validate_upsert_column_ref(
col_ref: &ColumnRef,
table: &TableSchema,
table_alias: Option<&str>,
) -> Result<(), CodegenError> {
if is_upsert_excluded_pseudo_table(col_ref, table, table_alias) {
if table_has_column_or_rowid(table, &col_ref.column) {
return Ok(());
}
return Err(qualified_column_not_found("excluded", &col_ref.column));
}
validate_single_table_column_ref(col_ref, table, table_alias)
}
fn is_upsert_excluded_pseudo_table(
col_ref: &ColumnRef,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
col_ref.table.as_deref().is_some_and(|qualifier| {
qualifier.eq_ignore_ascii_case("excluded")
&& !matches_table_or_alias(qualifier, table, table_alias)
})
}
fn table_has_column_or_rowid(table: &TableSchema, column: &str) -> bool {
table.column_index(column).is_some() || table.resolves_to_hidden_rowid(column)
}
/// Compute comparison p5 flags for a column reference.
///
/// Encodes NULLEQ (0x80) and the column's type affinity so the VDBE engine
/// applies correct text↔numeric coercion during comparison (§3.2).
#[allow(dead_code)]
fn column_cmp_p5(table: &TableSchema, resolved: &SortKeySource) -> u16 {
let affinity: u16 = match resolved {
SortKeySource::Column(idx) => u16::from(table.columns[*idx].affinity as u8),
SortKeySource::Rowid => u16::from(b'D'), // INTEGER
SortKeySource::Expression(_) => u16::from(b'A'), // BLOB (no coercion)
};
0x80 | affinity
}
/// Resolve a column reference to its 0-based index (ignoring rowid aliases).
///
/// Convenience wrapper for call sites that only care about real table columns.
fn resolve_column_index(expr: &Expr, table: &TableSchema) -> Option<usize> {
match resolve_column_ref(expr, table, None) {
Some(SortKeySource::Column(idx)) => Some(idx),
_ => None,
}
}
fn index_column_position(index: &IndexSchema, column_name: &str) -> Option<usize> {
index
.columns
.iter()
.position(|name| name.eq_ignore_ascii_case(column_name))
}
fn collect_conjunctive_terms<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) {
let mut pending = vec![expr];
while let Some(term) = pending.pop() {
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} = term
{
pending.push(right);
pending.push(left);
} else {
out.push(term);
}
}
}
fn expr_matches_index_column(
expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
expected_column: &str,
) -> bool {
column_name(expr, table, table_alias)
.is_some_and(|column_name| column_name.eq_ignore_ascii_case(expected_column))
}
fn expressions_match_table_locally(
query: &Expr,
stored: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let mut normalized_query = query.clone();
let mut normalized_stored = stored.clone();
normalize_table_local_expression(&mut normalized_query, table, table_alias)
&& normalize_table_local_expression(&mut normalized_stored, table, table_alias)
&& normalized_query == normalized_stored
}
/// Canonicalize table-local columns before structural expression matching.
///
/// Schema expressions normally contain bare columns, while SELECT expressions
/// can use either the table name or its visible alias. Strip only those local
/// qualifiers and resolve every column to a stable column-index identity.
/// Hidden rowid synonyms and an INTEGER PRIMARY KEY alias share one identity.
/// Foreign qualifiers, unknown columns, and nested scopes fail closed.
fn normalize_table_local_expression(
expr: &mut Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let normalize = |expr: &mut Expr| normalize_table_local_expression(expr, table, table_alias);
match expr {
Expr::Literal(..) | Expr::BoundOuterValue { .. } | Expr::Placeholder(..) => true,
Expr::Column(column, _) => {
if column.table.as_deref().is_some_and(|qualifier| {
!qualifier.eq_ignore_ascii_case(&table.name)
&& !table_alias.is_some_and(|alias| qualifier.eq_ignore_ascii_case(alias))
}) {
return false;
}
column.table = None;
if let Some(index) = table.column_index(&column.column) {
column.column = if table.columns[index].is_ipk {
"__fsqlite_local_rowid".into()
} else {
format!("__fsqlite_local_column_{index}").into()
};
true
} else if table.resolves_to_hidden_rowid(&column.column) {
column.column = "__fsqlite_local_rowid".into();
true
} else {
false
}
}
Expr::BinaryOp { left, right, .. }
| Expr::JsonAccess {
expr: left,
path: right,
..
} => normalize(left) && normalize(right),
Expr::UnaryOp { expr, .. } | Expr::Collate { expr, .. } | Expr::IsNull { expr, .. } => {
normalize(expr)
}
Expr::Cast {
expr, type_name, ..
} => {
type_name.name.make_ascii_lowercase();
if let Some(arg) = &mut type_name.arg1 {
arg.make_ascii_lowercase();
}
if let Some(arg) = &mut type_name.arg2 {
arg.make_ascii_lowercase();
}
normalize(expr)
}
Expr::Between {
expr, low, high, ..
} => normalize(expr) && normalize(low) && normalize(high),
Expr::In { expr, set, .. } => {
normalize(expr)
&& match set {
InSet::List(items) => items.iter_mut().all(normalize),
InSet::Subquery(_) | InSet::Table(_) => false,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => normalize(expr) && normalize(pattern) && escape.as_deref_mut().is_none_or(normalize),
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref_mut().is_none_or(normalize)
&& whens
.iter_mut()
.all(|(when, then)| normalize(when) && normalize(then))
&& else_expr.as_deref_mut().is_none_or(normalize)
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
let args_local = match args {
FunctionArgs::Star => false,
FunctionArgs::List(args) => args.iter_mut().all(normalize),
};
args_local
&& order_by.iter_mut().all(|term| normalize(&mut term.expr))
&& filter.as_deref_mut().is_none_or(normalize)
&& over.is_none()
}
Expr::RowValue(items, _) => items.iter_mut().all(normalize),
Expr::Exists { .. } | Expr::Subquery(..) | Expr::Raise { .. } => false,
}
}
fn extract_index_column_equality_expr<'a>(
expr: &'a Expr,
table: &TableSchema,
table_alias: Option<&str>,
expected_column: &str,
) -> Option<&'a Expr> {
let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} = expr
else {
return None;
};
if expr_matches_index_column(left, table, table_alias, expected_column)
&& is_simple_constant(right)
{
return Some(right);
}
if expr_matches_index_column(right, table, table_alias, expected_column)
&& is_simple_constant(left)
{
return Some(left);
}
None
}
fn extract_index_equality_prefix_exprs<'a>(
index: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
where_clause: Option<&'a Expr>,
) -> Vec<&'a Expr> {
let Some(where_expr) = where_clause else {
return Vec::new();
};
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
let mut prefix_exprs = Vec::new();
for index_column in &index.columns {
let Some(expr) = conjuncts.iter().find_map(|term| {
extract_index_column_equality_expr(term, table, table_alias, index_column)
}) else {
break;
};
prefix_exprs.push(expr);
}
prefix_exprs
}
fn resolve_covering_output_sources(
columns: &[ResultColumn],
table: &TableSchema,
table_alias: Option<&str>,
index: &IndexSchema,
) -> Option<Vec<CoveringOutputSource>> {
let mut output = Vec::with_capacity(columns.len());
for col in columns {
match col {
ResultColumn::Expr { expr, .. } => {
match resolve_column_ref(expr, table, table_alias)? {
SortKeySource::Column(col_idx) => {
let column_name = &table.columns.get(col_idx)?.name;
let index_pos = index_column_position(index, column_name)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
output.push(CoveringOutputSource::IndexColumn(index_pos as i32));
}
SortKeySource::Rowid => output.push(CoveringOutputSource::Rowid),
SortKeySource::Expression(_) => return None,
}
}
ResultColumn::Star | ResultColumn::TableStar(_) => return None,
}
}
Some(output)
}
fn resolve_order_by_rowid_direction(
table: &TableSchema,
table_alias: Option<&str>,
columns: &[ResultColumn],
order_by: &[OrderingTerm],
) -> Option<SortDirection> {
let [term] = order_by else {
return None;
};
if term.nulls.is_some() {
return None;
}
let resolved_expr = resolve_order_by_output_expr(&term.expr, columns).unwrap_or(&term.expr);
matches!(
resolve_column_ref(resolved_expr, table, table_alias),
Some(SortKeySource::Rowid)
)
.then_some(term.direction.unwrap_or(SortDirection::Asc))
}
fn resolve_order_by_index_plan(
table: &TableSchema,
table_alias: Option<&str>,
columns: &[ResultColumn],
where_clause: Option<&Expr>,
order_by: &[OrderingTerm],
distinct: Distinctness,
) -> Option<OrderByIndexPlan> {
if order_by.is_empty() || distinct == Distinctness::Distinct {
return None;
}
// Keep the direction and effective collation attached to each resolved
// column. An equality-constrained index prefix is removed per candidate
// index below, so its (irrelevant) direction must not constrain the
// variable suffix.
let mut order_columns = Vec::with_capacity(order_by.len());
for term in order_by {
if term.nulls.is_some() {
return None;
}
let term_direction = term.direction.unwrap_or(SortDirection::Asc);
let resolved_expr = resolve_order_by_output_expr(&term.expr, columns).unwrap_or(&term.expr);
let Expr::Column(col_ref, _) = resolved_expr else {
return None;
};
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return None;
}
if table.resolves_to_hidden_rowid(&col_ref.column) {
return None;
}
order_columns.push((
col_ref.column.to_string(),
term_direction,
column_collation(resolved_expr, table, table_alias),
));
}
let mut best_plan: Option<OrderByIndexPlan> = None;
for index in &table.indexes {
if !index.supports_direct_column_lookup() {
continue;
}
let equality_prefix_len =
extract_index_equality_prefix_exprs(index, table, table_alias, where_clause).len();
// Equality probes and ORDER BY must agree with the index's collation.
// In particular, a NOCASE index cannot prove that a BINARY equality
// prefix is constant (or vice versa).
if (0..equality_prefix_len).any(|key_pos| {
let Some(column_idx) = table.column_index(&index.columns[key_pos]) else {
return true;
};
!collation_names_equivalent(
table.columns[column_idx].collation.as_deref(),
index.key_term_collation(key_pos),
)
}) {
continue;
}
// ORDER BY terms that repeat the leading equality-constrained index
// columns are constants within this query scope. Consume only the
// exact leading sequence; this deliberately declines reordered,
// duplicate, COLLATE-wrapped, or otherwise non-obvious forms.
let fixed_order_prefix_len = order_columns
.iter()
.zip(index.columns.iter().take(equality_prefix_len))
.enumerate()
.take_while(|(key_pos, ((order_col, _, order_collation), index_col))| {
order_col.eq_ignore_ascii_case(index_col)
&& collation_names_equivalent(
*order_collation,
index.key_term_collation(*key_pos),
)
})
.count();
let variable_order = &order_columns[fixed_order_prefix_len..];
if equality_prefix_len + variable_order.len() > index.key_term_count() {
continue;
}
let direction = variable_order.first().map(|(_, direction, _)| *direction);
if variable_order
.iter()
.any(|(_, term_direction, _)| Some(*term_direction) != direction)
{
continue;
}
let descending = direction == Some(SortDirection::Desc);
if equality_prefix_len > 0 && descending {
// The generic bounded-prefix emitter currently walks forward.
// Composite prefix+range DESC queries use their dedicated reverse
// seek path before reaching this resolver.
continue;
}
let matches_order_columns =
variable_order
.iter()
.enumerate()
.all(|(offset, (order_col, _, order_collation))| {
let key_pos = equality_prefix_len + offset;
!index.key_term_descending(key_pos)
&& index.columns[key_pos].eq_ignore_ascii_case(order_col)
&& collation_names_equivalent(
*order_collation,
index.key_term_collation(key_pos),
)
});
if !matches_order_columns {
continue;
}
let covering_output = if where_clause.is_none() {
resolve_covering_output_sources(columns, table, table_alias, index)
} else {
None
};
let candidate = OrderByIndexPlan {
index: index.clone(),
descending,
equality_prefix_len,
covering_output,
};
let should_replace = match best_plan.as_ref() {
None => true,
Some(existing) => {
candidate.equality_prefix_len > existing.equality_prefix_len
|| (candidate.equality_prefix_len == existing.equality_prefix_len
&& candidate.covering_output.is_some()
&& existing.covering_output.is_none())
}
};
if should_replace {
best_plan = Some(candidate);
}
}
best_plan
}
/// Resolve result columns to table column indices.
///
/// Returns a Vec of column indices for each output column.
/// `Star` and `TableStar` expand to all table columns.
///
/// NOTE: Currently unused — `emit_column_reads` handles non-column result
/// expressions directly. Kept for potential future index-only scan codegen.
#[allow(
dead_code,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn resolve_result_column_indices(
columns: &[ResultColumn],
table: &TableSchema,
) -> Result<Vec<usize>, CodegenError> {
let mut indices = Vec::new();
for col in columns {
match col {
ResultColumn::Star => {
indices.extend(0..table.columns.len());
}
ResultColumn::TableStar(qualifier) => {
if !qualifier.name.eq_ignore_ascii_case(&table.name) {
return Err(CodegenError::TableNotFound(qualifier.to_string()));
}
indices.extend(0..table.columns.len());
}
ResultColumn::Expr { expr, .. } => {
if let Expr::Column(col_ref, _) = expr {
let idx = table.column_index(&col_ref.column).ok_or_else(|| {
CodegenError::ColumnNotFound {
table: table.name.clone(),
column: col_ref.column.to_string(),
}
})?;
indices.push(idx);
} else {
return Err(CodegenError::Unsupported(
"non-column result expression in table-backed SELECT".to_owned(),
));
}
}
}
}
Ok(indices)
}
fn is_simple_constant(expr: &Expr) -> bool {
// This predicate is used only to admit values into raw physical
// rowid/index probes. A bound outer value is logically constant for one
// invocation, but its comparison still carries the outer expression's
// affinity and declared-collation metadata. The b-tree seek key does not
// encode that operand-order-dependent comparison contract, so treating a
// BoundOuterValue like a literal can make the probed key range either a
// subset or a superset of the SQL equality result. Keep literals and bind
// parameters on the fast path, and make correlated values use the
// comparator-correct scan path.
matches!(expr, Expr::Placeholder(..) | Expr::Literal(..))
}
fn is_rowid_range_constant(expr: &Expr) -> bool {
matches!(expr, Expr::Literal(..))
|| matches!(
expr,
Expr::Placeholder(
fsqlite_ast::PlaceholderType::Numbered(_)
| fsqlite_ast::PlaceholderType::ColonNamed(_)
| fsqlite_ast::PlaceholderType::AtNamed(_)
| fsqlite_ast::PlaceholderType::DollarNamed(_),
_
)
)
}
fn is_index_range_constant(expr: &Expr) -> bool {
// A negative numeric literal parses as `UnaryOp(Negate, Literal(..))`, which
// `is_rowid_range_constant` does not recognize. Accept it here so a range bound like
// `rr BETWEEN -1.0 AND 4.0` is extracted; `index_range_fast_path_is_safe` still gates
// whether the extracted range is seek-safe (and `is_numeric_literal_bound` mirrors this
// negated-literal shape), so broadening extraction cannot admit an unsafe seek.
is_rowid_range_constant(expr) || is_numeric_literal_bound(expr)
}
/// Whether an expression contains a value captured from an outer query scope.
///
/// Correlated rowid-probe extraction accepts expressions rather than only
/// literals, so the top-level constant gates above are not enough: a bound
/// value can be nested below CAST, arithmetic, CASE, or a function call. Such
/// a value must not become an authoritative physical seek key until that seek
/// can reproduce the full SQL comparison's operand order, affinity, and
/// collation.
fn expr_contains_bound_outer_value(expr: &Expr) -> bool {
match expr {
Expr::BoundOuterValue { .. } => true,
Expr::BinaryOp { left, right, .. }
| Expr::JsonAccess {
expr: left,
path: right,
..
} => expr_contains_bound_outer_value(left) || expr_contains_bound_outer_value(right),
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => expr_contains_bound_outer_value(expr),
Expr::Between {
expr, low, high, ..
} => {
expr_contains_bound_outer_value(expr)
|| expr_contains_bound_outer_value(low)
|| expr_contains_bound_outer_value(high)
}
Expr::In { expr, set, .. } => {
expr_contains_bound_outer_value(expr)
|| matches!(
set,
InSet::List(values) if values.iter().any(expr_contains_bound_outer_value)
)
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
expr_contains_bound_outer_value(expr)
|| expr_contains_bound_outer_value(pattern)
|| escape
.as_deref()
.is_some_and(expr_contains_bound_outer_value)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_some_and(expr_contains_bound_outer_value)
|| whens.iter().any(|(when_expr, then_expr)| {
expr_contains_bound_outer_value(when_expr)
|| expr_contains_bound_outer_value(then_expr)
})
|| else_expr
.as_deref()
.is_some_and(expr_contains_bound_outer_value)
}
Expr::FunctionCall {
args,
order_by,
filter,
..
} => {
matches!(
args,
FunctionArgs::List(values)
if values.iter().any(expr_contains_bound_outer_value)
) || order_by
.iter()
.any(|term| expr_contains_bound_outer_value(&term.expr))
|| filter
.as_deref()
.is_some_and(expr_contains_bound_outer_value)
}
Expr::RowValue(values, _) => values.iter().any(expr_contains_bound_outer_value),
// Nested SELECT scopes are rejected separately by the rowid-probe
// admission gate. They are opaque here by design.
Expr::Exists { .. }
| Expr::Subquery(..)
| Expr::Literal(..)
| Expr::Column(..)
| Expr::Placeholder(..)
| Expr::Raise { .. } => false,
}
}
/// Check if a WHERE clause is a simple `rowid = ?` bind parameter.
///
/// Returns the 1-based bind parameter index if so.
#[allow(dead_code)]
fn extract_rowid_bind_param(
where_clause: Option<&Expr>,
table: Option<&TableSchema>,
table_alias: Option<&str>,
) -> Option<i32> {
let expr = extract_rowid_target_expr(where_clause, table, table_alias)?;
bind_param_index(expr)
}
fn extract_rowid_target_expr<'a>(
where_clause: Option<&'a Expr>,
table: Option<&TableSchema>,
table_alias: Option<&str>,
) -> Option<&'a Expr> {
let expr = where_clause?;
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} = expr
{
if is_rowid_expr(left, table, table_alias) && is_simple_constant(right) {
return Some(right);
}
if is_rowid_expr(right, table, table_alias) && is_simple_constant(left) {
return Some(left);
}
}
None
}
/// The `rowid = <const>` target when it is a conjunct alongside OTHER predicates the SeekRowid cannot
/// enforce — `rowid = <const> AND <residual>`. Returns the const RHS; the caller does one SeekRowid and
/// re-applies the whole WHERE per the single row (the target row is a SUPERSET of size 1, the residual
/// narrows to exact). Declines the BARE `rowid = <const>` (single conjunct) so that shape keeps flowing
/// through the existing RowidLookup directive path unchanged (golden snapshots stable). The residual
/// sibling of [`extract_rowid_target_expr`], mirroring [`extract_rowid_in_list_residual_target`].
/// bd-nonagg-rowid-eq-residual.
fn extract_rowid_eq_residual_target<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<&'a Expr> {
let where_expr = where_clause?;
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
if conjuncts.len() < 2 {
return None;
}
for term in conjuncts {
if let Some(expr) = extract_rowid_target_expr(Some(term), Some(table), table_alias) {
return Some(expr);
}
}
None
}
/// The distinct integer values a `WHERE <rowid> IN (<int list>)` predicate can seek per value.
///
/// bd-2dgf5. Integer literals only (`SeekRowid` truncates via `to_integer()`, so `id = 2.5`
/// would wrongly match rowid 2; negatives parse as unary-negate and decline), `IN` not
/// `NOT IN`, non-empty. De-duplicated. Anything else declines to the scan.
/// Collect the leaves of a pure `OR` chain of `<expr> = <integer literal>` into `out`.
///
/// Returns false if any leaf is not `something = int` / `int = something`. The caller checks
/// that every collected column expression is the SAME column, so `k = 2 OR j = 3` is rejected.
/// Precedence is respected by the AST: `k = 2 OR k = 5 AND x` parses as `k = 2 OR (k = 5 AND x)`,
/// whose right leaf is an `And` and fails here.
fn collect_or_int_eq_leaves<'a>(expr: &'a Expr, out: &mut Vec<(&'a Expr, i64)>) -> bool {
match expr {
Expr::BinaryOp {
op: fsqlite_ast::BinaryOp::Or,
left,
right,
..
} => collect_or_int_eq_leaves(left, out) && collect_or_int_eq_leaves(right, out),
Expr::BinaryOp {
op: fsqlite_ast::BinaryOp::Eq,
left,
right,
..
} => {
if let Expr::Literal(Literal::Integer(n), _) = right.as_ref() {
out.push((left, *n));
true
} else if let Expr::Literal(Literal::Integer(n), _) = left.as_ref() {
out.push((right, *n));
true
} else {
false
}
}
_ => false,
}
}
/// The column expression and distinct sorted integer values of a `WHERE col IN (<int list>)`
/// OR a semantically equivalent `WHERE col = a OR col = b OR ...` predicate.
///
/// bd-2dgf5. Integer literals only (exact probe, no affinity coercion), same column across an
/// OR chain, `IN` not `NOT IN`, non-empty. Values are de-duplicated, so `col = a OR col = a`
/// and `IN (a, a)` both collapse to one seek, matching SQLite's set semantics.
fn column_int_list_from_predicate<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(&'a Expr, Vec<i64>)> {
let expr = where_clause?;
let (column, mut ints): (&Expr, Vec<i64>) = if let Expr::In {
expr: column,
set: fsqlite_ast::InSet::List(values),
not: false,
..
} = expr
{
if values.is_empty() {
return None;
}
let mut ints = Vec::with_capacity(values.len());
for value in values {
match value {
Expr::Literal(Literal::Integer(n), _) => ints.push(*n),
_ => return None,
}
}
(column.as_ref(), ints)
} else if matches!(
expr,
Expr::BinaryOp {
op: fsqlite_ast::BinaryOp::Or,
..
}
) {
// Only an actual OR chain normalizes here. A bare `col = <int>` is left to the
// existing rowid-equality / index-equality paths (which own its EQP shape); treating
// it as a one-element IN-list would re-route it and change its EXPLAIN output.
let mut leaves: Vec<(&Expr, i64)> = Vec::new();
if !collect_or_int_eq_leaves(expr, &mut leaves) || leaves.is_empty() {
return None;
}
// Same-column check, rowid-aware: `column_name` returns None for the rowid alias, so
// fold rowid refs to a single identity. This keeps `id = 2 OR id = 3` (and `id = 2 OR
// rowid = 3`, the same column) together while rejecting `k = 2 OR j = 3`.
let identity = |e: &Expr| -> Option<String> {
if is_rowid_expr(e, Some(table), table_alias) {
Some("\0rowid".to_owned())
} else {
column_name(e, table, table_alias)
}
};
let first_col = leaves[0].0;
let first_ident = identity(first_col)?;
for (col, _) in &leaves {
if identity(col).as_deref() != Some(first_ident.as_str()) {
return None;
}
}
let ints = leaves.iter().map(|(_, n)| *n).collect();
(first_col, ints)
} else {
return None;
};
ints.sort_unstable();
ints.dedup();
Some((column, ints))
}
fn extract_rowid_in_list_target(
where_clause: Option<&Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<Vec<i64>> {
let (column, ints) = column_int_list_from_predicate(where_clause, table, table_alias)?;
is_rowid_expr(column, Some(table), table_alias).then_some(ints)
}
/// `(values, has_residual)`. `false` = the whole WHERE is `rowid IN (ints)`. `true` = it is a conjunct
/// alongside OTHER predicates the SeekRowid probes cannot enforce; the caller re-applies the whole WHERE
/// per row (the listed rowids are a SUPERSET, the residual narrows to exact). Mirrors
/// [`index_integer_in_list_residual_target`]. bd-nonagg-rowid-in-residual.
fn extract_rowid_in_list_residual_target(
where_clause: Option<&Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(Vec<i64>, bool)> {
if let Some(ints) = extract_rowid_in_list_target(where_clause, table, table_alias) {
return Some((ints, false));
}
let where_expr = where_clause?;
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
if conjuncts.len() < 2 {
return None;
}
for term in &conjuncts {
if let Some(ints) = extract_rowid_in_list_target(Some(term), table, table_alias) {
return Some((ints, true));
}
}
None
}
fn extract_rowid_range_target<'a>(
where_clause: Option<&'a Expr>,
table: Option<&TableSchema>,
table_alias: Option<&str>,
) -> Option<RowidRangeTarget<'a>> {
let expr = where_clause?;
let mut target = RowidRangeTarget::default();
if collect_rowid_range_bounds(expr, table, table_alias, &mut target)
&& (target.lower.is_some() || target.upper.is_some())
{
Some(target)
} else {
None
}
}
/// Returns `(range, has_residual)`. `has_residual == false` is the plain rowid-range case (whole WHERE
/// is a rowid range). `has_residual == true` additionally allows a rowid range that coexists with OTHER
/// (placeholder-free) predicates the seek cannot enforce: the rowid range visits a SUPERSET of the
/// matching rows and the caller re-applies the whole WHERE as a residual filter per row. Aggregates are
/// order-independent, so visiting a superset and filtering is byte-exact. Both cases require the range
/// to pass `rowid_range_fast_path_is_safe`.
fn extract_rowid_range_residual_target<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(RowidRangeTarget<'a>, bool)> {
let expr = where_clause?;
if let Some(range) = extract_rowid_range_target(where_clause, Some(table), table_alias) {
return rowid_range_fast_path_is_safe(range).then_some((range, false));
}
let mut conjuncts = Vec::new();
collect_conjunctive_terms(expr, &mut conjuncts);
let mut target = RowidRangeTarget::default();
for term in &conjuncts {
match term {
Expr::BinaryOp {
left, op, right, ..
} => {
if let Some((slot, bound)) =
extract_rowid_range_bound(left, *op, right, Some(table), table_alias)
&& !assign_rowid_range_bound(&mut target, slot, bound)
{
return None; // conflicting duplicate bound
}
}
Expr::Between {
expr: operand,
low,
high,
not: false,
..
} if is_rowid_expr(operand, Some(table), table_alias)
&& is_rowid_range_constant(low)
&& is_rowid_range_constant(high) =>
{
let bounds_assigned = assign_rowid_range_bound(
&mut target,
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: operand,
expr: low,
inclusive: true,
},
) && assign_rowid_range_bound(
&mut target,
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: operand,
expr: high,
inclusive: true,
},
);
if !bounds_assigned {
return None;
}
}
_ => {} // non-rowid conjunct: a residual the filter enforces
}
}
if target.lower.is_none() && target.upper.is_none() {
return None;
}
// The seek probe emits the bound values, so require integer-LITERAL bounds: then it emits no
// anonymous placeholders and a `?` can appear only in the residual, where the filter numbers it
// identically to the scan path (bd-agg-param-residual). A placeholder bound (`id > ?`) declines.
let literal_bound = |b: &Option<RowidRangeBound<'a>>| {
b.as_ref()
.is_none_or(|bound| matches!(bound.expr, Expr::Literal(Literal::Integer(_), _)))
};
if !literal_bound(&target.lower) || !literal_bound(&target.upper) {
return None;
}
rowid_range_fast_path_is_safe(target).then_some((target, true))
}
fn collect_rowid_range_bounds<'a>(
expr: &'a Expr,
table: Option<&TableSchema>,
table_alias: Option<&str>,
target: &mut RowidRangeTarget<'a>,
) -> bool {
match expr {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
collect_rowid_range_bounds(left, table, table_alias, target)
&& collect_rowid_range_bounds(right, table, table_alias, target)
}
Expr::BinaryOp {
left, op, right, ..
} => extract_rowid_range_bound(left, *op, right, table, table_alias)
.is_some_and(|(slot, bound)| assign_rowid_range_bound(target, slot, bound)),
Expr::Between {
expr: operand,
low,
high,
not: false,
..
} if is_rowid_expr(operand, table, table_alias)
&& is_rowid_range_constant(low)
&& is_rowid_range_constant(high) =>
{
assign_rowid_range_bound(
target,
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: operand,
expr: low,
inclusive: true,
},
) && assign_rowid_range_bound(
target,
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: operand,
expr: high,
inclusive: true,
},
)
}
_ => false,
}
}
#[derive(Clone, Copy)]
enum RowidRangeSlot {
Lower,
Upper,
}
fn extract_rowid_range_bound<'a>(
left: &'a Expr,
op: fsqlite_ast::BinaryOp,
right: &'a Expr,
table: Option<&TableSchema>,
table_alias: Option<&str>,
) -> Option<(RowidRangeSlot, RowidRangeBound<'a>)> {
if is_rowid_expr(left, table, table_alias) && is_rowid_range_constant(right) {
return match op {
fsqlite_ast::BinaryOp::Ge => Some((
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: left,
expr: right,
inclusive: true,
},
)),
fsqlite_ast::BinaryOp::Gt => Some((
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: left,
expr: right,
inclusive: false,
},
)),
fsqlite_ast::BinaryOp::Le => Some((
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: left,
expr: right,
inclusive: true,
},
)),
fsqlite_ast::BinaryOp::Lt => Some((
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: left,
expr: right,
inclusive: false,
},
)),
_ => None,
};
}
if is_rowid_expr(right, table, table_alias) && is_rowid_range_constant(left) {
return match op {
fsqlite_ast::BinaryOp::Le => Some((
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: right,
expr: left,
inclusive: true,
},
)),
fsqlite_ast::BinaryOp::Lt => Some((
RowidRangeSlot::Lower,
RowidRangeBound {
rowid_expr: right,
expr: left,
inclusive: false,
},
)),
fsqlite_ast::BinaryOp::Ge => Some((
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: right,
expr: left,
inclusive: true,
},
)),
fsqlite_ast::BinaryOp::Gt => Some((
RowidRangeSlot::Upper,
RowidRangeBound {
rowid_expr: right,
expr: left,
inclusive: false,
},
)),
_ => None,
};
}
None
}
fn assign_rowid_range_bound<'a>(
target: &mut RowidRangeTarget<'a>,
slot: RowidRangeSlot,
bound: RowidRangeBound<'a>,
) -> bool {
let target_slot = match slot {
RowidRangeSlot::Lower => &mut target.lower,
RowidRangeSlot::Upper => &mut target.upper,
};
if target_slot.is_some() {
false
} else {
*target_slot = Some(bound);
true
}
}
/// Check if a WHERE clause is `col = ?` for an indexed column.
fn extract_column_eq_target<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(String, &'a Expr)> {
let expr = where_clause?;
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} = expr
{
if let Some(col_name) = column_name(left, table, table_alias)
&& is_simple_constant(right)
{
return Some((col_name, right));
}
if let Some(col_name) = column_name(right, table, table_alias)
&& is_simple_constant(left)
{
return Some((col_name, left));
}
}
None
}
/// Extract the `<directive-key-column> = <simple constant>` conjunct from a
/// conjunction-shaped WHERE clause (bd-kwaam / #377).
///
/// The planner classifies equality terms inside AND-trees when it emits an
/// `IndexEquality` directive, but [`extract_column_eq_target`] only matches
/// single-term predicates, so every multi-term guard (`cap = ? AND ord = ?`
/// against a composite UNIQUE index) used to bypass the seek to a heuristic
/// full scan — catastrophically on WITHOUT ROWID tables, which have no other
/// indexed path. This recovers the directive's own conjunct; callers lower it
/// through the residual-filtering emitter so the remaining conjuncts stay
/// enforced. OR subtrees are skipped: a disjunction is not an equality
/// conjunct and must keep declining.
fn extract_labeled_eq_conjunct_target<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
key_label: &str,
) -> Option<&'a Expr> {
let mut stack = vec![where_clause?];
while let Some(expr) = stack.pop() {
match expr {
Expr::BinaryOp {
op: fsqlite_ast::BinaryOp::And,
left,
right,
..
} => {
stack.push(left);
stack.push(right);
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} => {
if let Some(col_name) = column_name(left, table, table_alias)
&& col_name.eq_ignore_ascii_case(key_label)
&& is_simple_constant(right)
{
return Some(right);
}
if let Some(col_name) = column_name(right, table, table_alias)
&& col_name.eq_ignore_ascii_case(key_label)
&& is_simple_constant(left)
{
return Some(left);
}
}
_ => {}
}
}
None
}
fn extract_expression_index_equality_expr<'a>(
where_clause: Option<&'a Expr>,
index: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<&'a Expr> {
let root = where_clause?;
let key_expr = parse_sql_expr(index.key_term_sql(0)?).ok()?;
let mut pending = vec![root];
let mut residuals = Vec::new();
let mut target = None;
while let Some(expr) = pending.pop() {
let Expr::BinaryOp {
left, op, right, ..
} = expr
else {
residuals.push(expr);
continue;
};
if *op == fsqlite_ast::BinaryOp::And {
pending.push(right);
pending.push(left);
continue;
}
if *op != fsqlite_ast::BinaryOp::Eq {
residuals.push(expr);
continue;
}
let candidate = if expressions_match_table_locally(left, &key_expr, table, table_alias)
&& is_simple_constant(right)
{
Some(right.as_ref())
} else if expressions_match_table_locally(right, &key_expr, table, table_alias)
&& is_simple_constant(left)
{
Some(left.as_ref())
} else {
None
};
if let Some(candidate) = candidate {
if target.replace(candidate).is_some() {
return None;
}
} else {
residuals.push(expr);
}
}
target.filter(|_| expression_index_residuals_match(&residuals, index, table, table_alias))
}
#[derive(Clone, Copy)]
enum PlannerIndexRangeSlot {
Lower,
Upper,
}
fn extract_expression_index_range_target(
where_clause: Option<&Expr>,
index: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<PlannerIndexRangeTarget> {
let expr = where_clause?;
let key_expr = parse_sql_expr(index.key_term_sql(0)?).ok()?;
let mut target = PlannerIndexRangeTarget::default();
let mut residuals = Vec::new();
if collect_expression_index_range_bounds(
expr,
&key_expr,
table,
table_alias,
&mut target,
&mut residuals,
) && (target.lower.is_some() || target.upper.is_some())
&& expression_index_residuals_match(&residuals, index, table, table_alias)
{
Some(target)
} else {
None
}
}
fn collect_expression_index_range_bounds<'a>(
root: &'a Expr,
key_expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
target: &mut PlannerIndexRangeTarget,
residuals: &mut Vec<&'a Expr>,
) -> bool {
let mut found_bound = false;
let mut pending = vec![root];
while let Some(expr) = pending.pop() {
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} = expr
{
pending.push(right);
pending.push(left);
continue;
}
if let Expr::BinaryOp {
left, op, right, ..
} = expr
&& let Some((slot, bound)) =
extract_expression_index_range_bound(left, *op, right, key_expr, table, table_alias)
{
if !assign_expression_index_range_bound(target, slot, bound) {
return false;
}
found_bound = true;
continue;
}
if let Expr::Between {
expr: operand,
low,
high,
not: false,
..
} = expr
&& expressions_match_table_locally(operand, key_expr, table, table_alias)
&& is_index_range_constant(low)
&& is_index_range_constant(high)
{
if !assign_expression_index_range_bound(
target,
PlannerIndexRangeSlot::Lower,
PlannerIndexRangeBound {
expr: low.as_ref().clone(),
inclusive: true,
},
) || !assign_expression_index_range_bound(
target,
PlannerIndexRangeSlot::Upper,
PlannerIndexRangeBound {
expr: high.as_ref().clone(),
inclusive: true,
},
) {
return false;
}
found_bound = true;
continue;
}
residuals.push(expr);
}
found_bound
}
fn expression_index_residuals_match(
residuals: &[&Expr],
index: &IndexSchema,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let Some(predicate_sql) = index.where_clause.as_deref() else {
return residuals.is_empty();
};
let Ok(predicate) = parse_sql_expr(predicate_sql) else {
return false;
};
let mut predicate_terms = Vec::new();
collect_conjunctive_terms(&predicate, &mut predicate_terms);
predicate_terms.iter().all(|predicate_term| {
residuals.iter().any(|residual| {
expressions_match_table_locally(residual, predicate_term, table, table_alias)
})
}) && residuals.iter().all(|residual| {
predicate_terms.iter().any(|predicate_term| {
expressions_match_table_locally(residual, predicate_term, table, table_alias)
})
})
}
fn extract_expression_index_range_bound(
left: &Expr,
op: fsqlite_ast::BinaryOp,
right: &Expr,
key_expr: &Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(PlannerIndexRangeSlot, PlannerIndexRangeBound)> {
use fsqlite_ast::BinaryOp::{Ge, Gt, Le, Lt};
if expressions_match_table_locally(left, key_expr, table, table_alias)
&& is_index_range_constant(right)
{
return match op {
Ge => Some((
PlannerIndexRangeSlot::Lower,
PlannerIndexRangeBound {
expr: right.clone(),
inclusive: true,
},
)),
Gt => Some((
PlannerIndexRangeSlot::Lower,
PlannerIndexRangeBound {
expr: right.clone(),
inclusive: false,
},
)),
Le => Some((
PlannerIndexRangeSlot::Upper,
PlannerIndexRangeBound {
expr: right.clone(),
inclusive: true,
},
)),
Lt => Some((
PlannerIndexRangeSlot::Upper,
PlannerIndexRangeBound {
expr: right.clone(),
inclusive: false,
},
)),
_ => None,
};
}
if expressions_match_table_locally(right, key_expr, table, table_alias)
&& is_index_range_constant(left)
{
return match op {
Le => Some((
PlannerIndexRangeSlot::Lower,
PlannerIndexRangeBound {
expr: left.clone(),
inclusive: true,
},
)),
Lt => Some((
PlannerIndexRangeSlot::Lower,
PlannerIndexRangeBound {
expr: left.clone(),
inclusive: false,
},
)),
Ge => Some((
PlannerIndexRangeSlot::Upper,
PlannerIndexRangeBound {
expr: left.clone(),
inclusive: true,
},
)),
Gt => Some((
PlannerIndexRangeSlot::Upper,
PlannerIndexRangeBound {
expr: left.clone(),
inclusive: false,
},
)),
_ => None,
};
}
None
}
fn assign_expression_index_range_bound(
target: &mut PlannerIndexRangeTarget,
slot: PlannerIndexRangeSlot,
bound: PlannerIndexRangeBound,
) -> bool {
let target_slot = match slot {
PlannerIndexRangeSlot::Lower => &mut target.lower,
PlannerIndexRangeSlot::Upper => &mut target.upper,
};
if target_slot.is_some() {
false
} else {
*target_slot = Some(bound);
true
}
}
enum CountIndexedInTarget<'a> {
List(&'a [Expr]),
ProbeSource(InProbeSource<'a>),
MaterializedProbeSource(InProbeSource<'a>),
}
fn extract_count_indexed_in_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&'a str>,
schema: &'a [TableSchema],
scan_ctx: &ScanCtx<'a>,
index_hint: Option<&fsqlite_ast::IndexHint>,
) -> Option<(&'a IndexSchema, CountIndexedInTarget<'a>)> {
let expr = where_clause?;
let Expr::In {
expr: operand,
set,
not: false,
..
} = expr
else {
return None;
};
let column_name = column_name(operand, table, table_alias)?;
// Search all indexes so a composite leading-column match cannot shadow a
// later usable single-column index. Composite indexes are safe only for
// the ordered semijoin below: the materialized/list fallback seeks with a
// two-field `(key, min-rowid)` record, whose physical contract is exactly a
// single-key index followed by rowid.
let find_index = |allow_composite: bool| {
table.indexes.iter().find(|idx| {
let matches_hint = match index_hint {
None => true,
Some(fsqlite_ast::IndexHint::IndexedBy(name)) => {
idx.name.eq_ignore_ascii_case(name)
}
Some(fsqlite_ast::IndexHint::NotIndexed) => false,
};
matches_hint
&& idx.supports_direct_column_lookup()
&& (allow_composite || idx.key_term_count() == 1)
&& !idx.key_term_descending(0)
&& idx
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&column_name))
&& collation_names_equivalent(
effective_collation_ctx(operand, Some(scan_ctx)),
idx.key_term_collation(0),
)
})
};
match set {
InSet::List(values)
if can_use_once_materialized_in_list(values, operand, Some(scan_ctx)) =>
{
let idx_schema = find_index(false)?;
Some((idx_schema, CountIndexedInTarget::List(values)))
}
InSet::Subquery(_) => {
let probe_source = resolve_in_probe_source(set, schema)?;
if let Some(idx_schema) = find_index(true).filter(|idx_schema| {
can_use_direct_count_indexed_in_subquery_probe_source(
table,
idx_schema,
&probe_source,
operand,
scan_ctx,
)
}) {
Some((idx_schema, CountIndexedInTarget::ProbeSource(probe_source)))
} else {
let idx_schema = find_index(false)?;
can_use_once_materialized_in_probe_source(&probe_source, operand, scan_ctx)
.then_some((
idx_schema,
CountIndexedInTarget::MaterializedProbeSource(probe_source),
))
}
}
InSet::Table(_) => {
let probe_source = resolve_in_probe_source(set, schema)?;
let idx_schema = find_index(false)?;
can_use_once_materialized_in_probe_source(&probe_source, operand, scan_ctx).then_some((
idx_schema,
CountIndexedInTarget::MaterializedProbeSource(probe_source),
))
}
InSet::List(_) => None,
}
}
fn collation_names_equivalent(left: Option<&str>, right: Option<&str>) -> bool {
let left = match left {
Some(collation) if !collation.eq_ignore_ascii_case("BINARY") => Some(collation),
_ => None,
};
let right = match right {
Some(collation) if !collation.eq_ignore_ascii_case("BINARY") => Some(collation),
_ => None,
};
match (left, right) {
(Some(left), Some(right)) => left.eq_ignore_ascii_case(right),
(None, None) => true,
_ => false,
}
}
fn extract_count_indexed_exists_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&'a str>,
schema: &'a [TableSchema],
) -> Option<(&'a IndexSchema, CountIndexedInTarget<'a>)> {
let expr = where_clause?;
let Expr::Exists {
subquery,
not: false,
..
} = expr
else {
return None;
};
if subquery.with.is_some()
|| !subquery.body.compounds.is_empty()
|| !subquery.order_by.is_empty()
|| subquery.limit.is_some()
{
return None;
}
let SelectCore::Select {
from,
where_clause: Some(sub_where),
group_by,
having,
windows,
..
} = &subquery.body.select
else {
return None;
};
if !group_by.is_empty() || having.is_some() || !windows.is_empty() {
return None;
}
let from_clause = from.as_ref()?;
if !from_clause.joins.is_empty() {
return None;
}
let (sub_table_name, sub_alias) = match &from_clause.source {
TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.as_deref()),
_ => return None,
};
let sub_table = find_table(schema, sub_table_name).ok()?;
let (probe_expr, residual_terms) = extract_exists_rowid_probe(sub_where, sub_table, sub_alias)?;
let outer_column_name = column_name(probe_expr, table, table_alias)?;
let idx_schema = table.index_for_column(&outer_column_name)?;
if idx_schema.key_term_count() != 1 || idx_schema.key_term_descending(0) {
return None;
}
// This plan probes the outer index directly with inner rowids and cannot
// defer to an SQL comparison opcode to apply affinity. A TEXT/BLOB outer
// key can compare equal only after numeric affinity while its raw index key
// occupies a different storage-class range, so retain only the established
// numeric-storage subset.
if !count_rowid_probe_index_is_seek_compatible(table, idx_schema) {
return None;
}
if residual_terms
.iter()
.any(|term| expr_references_scan(term, table, table_alias))
{
return None;
}
let residual_where = match residual_terms.as_slice() {
[] => None,
[single] => Some(*single),
_ => return None,
};
Some((
idx_schema,
CountIndexedInTarget::ProbeSource(InProbeSource {
table: sub_table,
table_alias: sub_alias,
where_clause: residual_where,
value: InProbeValue::Rowid,
}),
))
}
#[derive(Clone, Copy)]
enum ColumnRangeSlot {
Lower,
Upper,
}
fn extract_column_range_target<'a>(
where_clause: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(String, ColumnRangeTarget<'a>)> {
let expr = where_clause?;
let mut column_name = None;
let mut target = ColumnRangeTarget::default();
if collect_column_range_bounds(expr, table, table_alias, &mut column_name, &mut target)
&& (target.lower.is_some() || target.upper.is_some())
{
column_name.map(|col| (col, target))
} else {
None
}
}
/// Extract the range bounds on a specific named column from the conjuncts of `where_expr`,
/// ignoring every other conjunct — so a composite `a = 5 AND b > 10` yields the `b` range even
/// though `a = 5` is not a range term. Returns `None` if the column has no range term, or a
/// conflicting duplicate bound.
fn extract_named_column_range<'a>(
where_expr: &'a Expr,
table: &TableSchema,
table_alias: Option<&str>,
column: &str,
) -> Option<ColumnRangeTarget<'a>> {
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
let mut target = ColumnRangeTarget::default();
for term in conjuncts {
match term {
Expr::BinaryOp {
left, op, right, ..
} => {
if let Some((col, slot, bound)) =
extract_column_range_bound(left, *op, right, table, table_alias)
&& col.eq_ignore_ascii_case(column)
{
match slot {
ColumnRangeSlot::Lower => {
if target.lower.is_some() {
return None;
}
target.lower = Some(bound);
}
ColumnRangeSlot::Upper => {
if target.upper.is_some() {
return None;
}
target.upper = Some(bound);
}
}
}
}
Expr::Between {
expr: operand,
low,
high,
not: false,
..
} if is_index_range_constant(low)
&& is_index_range_constant(high)
&& column_name(operand, table, table_alias)
.is_some_and(|c| c.eq_ignore_ascii_case(column)) =>
{
if target.lower.is_some() || target.upper.is_some() {
return None;
}
target.lower = Some(borrowed_column_range_bound(low, true));
target.upper = Some(borrowed_column_range_bound(high, true));
}
_ => {}
}
}
(target.lower.is_some() || target.upper.is_some()).then_some(target)
}
/// A composite-index seek target: an equality prefix on the leading key columns plus a range on
/// the next key column (`WHERE a = 5 AND b > 10` on `index(a, b)`).
struct CompositePrefixRange<'a> {
index: &'a IndexSchema,
prefix_exprs: Vec<&'a Expr>,
range: ColumnRangeTarget<'a>,
}
/// Whether a single WHERE conjunct is fully enforced by the composite prefix+range seek — i.e. it is
/// an equality on one of the pinned prefix columns, or an equality / range comparison (`>`,`>=`,`<`,
/// `<=`) / `BETWEEN` on the range column. The seek applies NO residual filter, so any conjunct that is
/// none of these (e.g. `c = 1` on a non-key column, or a second, unpinnable predicate) would be
/// silently dropped; the caller declines then, letting the full scan enforce the whole WHERE.
fn conjunct_pins_prefix_or_range(
term: &Expr,
index: &IndexSchema,
range_pos: usize,
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
if index.columns[..range_pos]
.iter()
.any(|pcol| extract_index_column_equality_expr(term, table, table_alias, pcol).is_some())
{
return true;
}
let range_col = &index.columns[range_pos];
// Equality on the range column covers the demoted full-eq case (last column's `=` → degenerate range).
if extract_index_column_equality_expr(term, table, table_alias, range_col).is_some() {
return true;
}
match term {
Expr::BinaryOp {
left, op, right, ..
} => extract_column_range_bound(left, *op, right, table, table_alias)
.is_some_and(|(col, _, _)| col.eq_ignore_ascii_case(range_col)),
Expr::Between {
expr, not: false, ..
} => {
column_name(expr, table, table_alias).is_some_and(|c| c.eq_ignore_ascii_case(range_col))
}
_ => false,
}
}
/// Detect a composite-index equality-prefix + trailing-range seek. Requires a plain ascending
/// composite index whose leading columns are all pinned by `= <lit/param>` conjuncts and whose
/// next column has a range; the prefix and range bounds must be seek-safe (same affinity/collation
/// gate as the single-column range seek). Declines WITHOUT ROWID (PK-suffix probe not handled here).
fn composite_index_prefix_range_target<'a>(
where_clause: Option<&'a Expr>,
table: &'a TableSchema,
table_alias: Option<&str>,
schema: &[TableSchema],
required_index: Option<&str>,
) -> Option<CompositePrefixRange<'a>> {
if table.without_rowid {
return None;
}
let where_expr = where_clause?;
for index in &table.indexes {
// GH #291: an `INDEXED BY` hint pins the candidate set to the named
// index (SQLite name resolution is case-insensitive).
if required_index.is_some_and(|name| !index.name.eq_ignore_ascii_case(name)) {
continue;
}
let key_terms = index.key_term_count();
if key_terms < 2
|| index.columns.len() != key_terms
|| (0..key_terms).any(|i| index.key_term_descending(i))
{
continue;
}
let prefix_exprs =
extract_index_equality_prefix_exprs(index, table, table_alias, where_clause);
// An EMPTY equality prefix is a PURE range on the LEADING key term
// (`WHERE a <range>` on `index(a, …)`) — no equality pins. The `else`
// branch below then extracts the range on `columns[0]` (declining if the
// WHERE has no range on the leading term), and the residual guard rejects
// any unpinnable conjunct. This is the composite-index analogue of the
// single-column range fast path (bd-bn45n): without it a pure leading
// range over a composite-only index falls all the way back to a full
// table scan. ORDER BY is auto-declined for this shape
// (`composite_order_by_satisfied` requires the range on the LAST key
// term, so a leading-term range on a >=2-term index never elides the
// sorter), so the empty-prefix seek only serves the no-ORDER-BY case and
// any ORDER BY correctly falls to the sorter.
// When the prefix pins EVERY key column (`WHERE a=? AND b=?` on `(a,b)` — a full composite
// equality / point lookup), demote the last pinned column to a degenerate range `[c, c]` so the
// same prefix+range seek resolves it as one seek instead of a full scan.
let (prefix_exprs, range_pos, range) = if prefix_exprs.len() == key_terms {
// Safe to demote ONLY when the WHERE is EXACTLY these key-column equalities: the seek
// applies no residual filter, so a leftover predicate (e.g. a non-key column, as in
// `a=? AND b=? AND z=?` matched against a shorter `(a,b)` index) would be silently dropped.
// Requiring one conjunct per key term declines the short index and lets a fuller index win.
let mut conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut conjuncts);
if conjuncts.len() != key_terms {
continue;
}
let mut prefix = prefix_exprs;
let last = prefix.pop().expect("prefix is non-empty");
let range = ColumnRangeTarget {
lower: Some(borrowed_column_range_bound(last, true)),
upper: Some(borrowed_column_range_bound(last, true)),
};
(prefix, key_terms - 1, range)
} else {
let range_column = &index.columns[prefix_exprs.len()];
let Some(range) =
extract_named_column_range(where_expr, table, table_alias, range_column)
else {
continue;
};
let pos = prefix_exprs.len();
(prefix_exprs, pos, range)
};
// bd-agg-range-shadowed-index: a PURE leading-term range (empty equality prefix, so
// `range_pos == 0`) is fully served by a NARROWER single-column ascending index on that
// leading column, if one is declared — prefer it (the single-column range planner opens
// `idx_col`; SQLite likewise picks the narrower index). Only decline for the empty-prefix
// case: with an equality prefix the composite is required (a single-column index cannot pin
// `a=? AND b<range`). Mirrors the single-column range planner's usability test verbatim, so we
// never decline into a full table scan when the single-column index is unusable (desc/collation).
if range_pos == 0
&& table.indexes.iter().any(|other| {
other.supports_direct_column_lookup()
&& other.key_term_count() == 1
&& !other.key_term_descending(0)
&& other
.columns
.first()
.is_some_and(|c| c.eq_ignore_ascii_case(&index.columns[0]))
})
{
continue;
}
// Residual guard: the seek enforces only the pinned prefix equalities and the range on
// `columns[range_pos]`. If ANY conjunct is not one of those (a predicate on a non-key column,
// or a second unpinnable term), decline so the full scan enforces the whole WHERE — otherwise
// that predicate is silently dropped and wrong rows are returned. (bd-zqkrp residual bug.)
let mut all_conjuncts = Vec::new();
collect_conjunctive_terms(where_expr, &mut all_conjuncts);
if !all_conjuncts
.iter()
.all(|term| conjunct_pins_prefix_or_range(term, index, range_pos, table, table_alias))
{
continue;
}
let range_column = &index.columns[range_pos];
if !index_range_fast_path_is_safe(table, table_alias, schema, range_column, &range) {
continue;
}
let prefix_ok = index.columns[..range_pos]
.iter()
.zip(prefix_exprs.iter())
.all(|(col, expr)| {
index_range_bound_is_seek_safe(table, table_alias, schema, col, expr)
});
if !prefix_ok {
continue;
}
return Some(CompositePrefixRange {
index,
prefix_exprs,
range,
});
}
None
}
/// Whether an index-range seek that streams in `(range_col, rowid)` order satisfies `order_by`
/// bit-identically (ascending). Two forms qualify. First, `ORDER BY range_col, <rowid/ipk>` is a
/// unique total order every plan agrees on, so it is always safe. Second, a bare `ORDER BY
/// range_col` qualifies only when the index is UNIQUE: a unique index has no ties within the range
/// (each `range_col` value occurs once, and the range excludes NULLs), so ordering by `range_col`
/// alone is already a total order and is bit-identical to every plan. A bare `ORDER BY range_col`
/// on a NON-unique index is tie-ambiguous (a different plan may order equal-`range_col` rows
/// differently) and is left to the sorter; DESC/NULLS clauses decline. Shared by the single-column
/// and composite range seeks (both stream `(range_col, rowid)` when `range_col` is the last key
/// term).
fn range_order_by_is_deterministic(
range_col: &str,
order_by: &[OrderingTerm],
table: &TableSchema,
table_alias: Option<&str>,
index_is_unique: bool,
descending: bool,
) -> bool {
// Every term must sort in the walk's direction (ascending seek: no DESC; reverse seek: all DESC)
// with no NULLS clause.
let dir_ok = |term: &OrderingTerm| {
term.nulls.is_none() && matches!(term.direction, Some(SortDirection::Desc)) == descending
};
let is_range_col = |term: &OrderingTerm| {
dir_ok(term)
&& matches!(
resolve_column_ref(&term.expr, table, table_alias),
Some(SortKeySource::Column(idx))
if table.columns.get(idx).is_some_and(|c| c.name.eq_ignore_ascii_case(range_col))
)
};
let is_rowid = |term: &OrderingTerm| {
dir_ok(term)
&& matches!(
resolve_column_ref(&term.expr, table, table_alias),
Some(SortKeySource::Rowid)
)
};
match order_by {
[t0] => index_is_unique && is_range_col(t0),
[t0, t1] => is_range_col(t0) && is_rowid(t1),
_ => false,
}
}
/// Count leading ORDER BY terms that merely repeat equality-constrained
/// leading index columns.
///
/// Only direct column references in exact index-prefix order qualify. A
/// COLLATE wrapper, explicit NULLS ordering, qualifier from another query
/// scope, or collation mismatch stops consumption. Direction is intentionally
/// ignored: every qualifying row has the same value under the same collation,
/// so ASC versus DESC cannot change their order.
fn fixed_order_by_equality_prefix_len(
index: &IndexSchema,
equality_prefix_len: usize,
order_by: &[OrderingTerm],
table: &TableSchema,
table_alias: Option<&str>,
) -> usize {
order_by
.iter()
.zip(index.columns.iter().take(equality_prefix_len))
.enumerate()
.take_while(|(key_pos, (term, index_col))| {
term.nulls.is_none()
&& column_name(&term.expr, table, table_alias)
.is_some_and(|order_col| order_col.eq_ignore_ascii_case(index_col))
&& collation_names_equivalent(
column_collation(&term.expr, table, table_alias),
index.key_term_collation(*key_pos),
)
})
.count()
}
fn composite_order_by_satisfied(
comp: &CompositePrefixRange<'_>,
order_by: &[OrderingTerm],
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let prefix_len = comp.prefix_exprs.len();
let fixed_order_prefix_len =
fixed_order_by_equality_prefix_len(comp.index, prefix_len, order_by, table, table_alias);
// The range column must be the LAST key term so the seek's stream is exactly `(range_col, rowid)`.
prefix_len + 1 == comp.index.key_term_count()
&& range_order_by_is_deterministic(
&comp.index.columns[prefix_len],
&order_by[fixed_order_prefix_len..],
table,
table_alias,
comp.index.is_unique,
false,
)
}
/// Whether a composite prefix+range seek can satisfy `order_by` in DESCENDING order without a
/// sorter. Same shape as [`composite_order_by_satisfied`] but for the reverse walk: the range
/// column must be the last key term (so the reverse index stream is exactly `(range_col DESC,
/// rowid DESC)`), and the order must be the deterministic `range_col DESC[, id DESC]` — or a bare
/// `range_col DESC` when the index is UNIQUE (no ties within the range).
fn composite_order_by_satisfied_desc(
comp: &CompositePrefixRange<'_>,
order_by: &[OrderingTerm],
table: &TableSchema,
table_alias: Option<&str>,
) -> bool {
let prefix_len = comp.prefix_exprs.len();
let fixed_order_prefix_len =
fixed_order_by_equality_prefix_len(comp.index, prefix_len, order_by, table, table_alias);
prefix_len + 1 == comp.index.key_term_count()
&& range_order_by_is_deterministic(
&comp.index.columns[prefix_len],
&order_by[fixed_order_prefix_len..],
table,
table_alias,
comp.index.is_unique,
true,
)
}
fn borrowed_column_range_bound(expr: &Expr, inclusive: bool) -> ColumnRangeBound<'_> {
ColumnRangeBound {
expr: ColumnRangeExpr::Borrowed(expr),
inclusive,
}
}
fn owned_string_column_range_bound<'a>(value: String, inclusive: bool) -> ColumnRangeBound<'a> {
ColumnRangeBound {
expr: ColumnRangeExpr::Owned(Box::new(Expr::Literal(Literal::String(value), Span::ZERO))),
inclusive,
}
}
fn extract_pure_glob_prefix(pattern: &Expr) -> Option<String> {
let Expr::Literal(Literal::String(pattern), _) = pattern else {
return None;
};
let mut prefix = String::new();
let mut saw_trailing_star = false;
for ch in pattern.chars() {
match ch {
'*' => saw_trailing_star = true,
'?' | '[' => return None,
_ if saw_trailing_star => return None,
_ => prefix.push(ch),
}
}
(!prefix.is_empty() && saw_trailing_star).then_some(prefix)
}
fn extract_pure_like_prefix(pattern: &Expr, escape: Option<&Expr>) -> Option<String> {
let escape_char = match escape {
None => None,
Some(Expr::Literal(Literal::String(s), _)) => {
let mut chars = s.chars();
let ch = chars.next()?;
if chars.next().is_some() {
return None;
}
Some(ch)
}
Some(_) => return None,
};
let Expr::Literal(Literal::String(pattern), _) = pattern else {
return None;
};
let mut prefix = String::new();
let mut saw_trailing_percent = false;
let mut chars = pattern.chars();
while let Some(ch) = chars.next() {
if escape_char.is_some_and(|esc| esc == ch) {
if saw_trailing_percent {
return None;
}
prefix.push(chars.next()?);
continue;
}
match ch {
'%' => saw_trailing_percent = true,
'_' => return None,
_ if saw_trailing_percent => return None,
_ => prefix.push(ch),
}
}
(!prefix.is_empty() && saw_trailing_percent).then_some(prefix)
}
fn is_case_stable_like_prefix(prefix: &str) -> bool {
prefix.chars().all(|ch| !ch.is_ascii_alphabetic())
}
fn prefix_upper_bound(prefix: &str) -> Option<String> {
let mut chars: Vec<char> = prefix.chars().collect();
for idx in (0..chars.len()).rev() {
let codepoint = u32::from(chars[idx]);
if codepoint == u32::from(char::MAX) {
continue;
}
if let Some(next) = char::from_u32(codepoint + 1) {
chars[idx] = next;
chars.truncate(idx + 1);
return Some(chars.into_iter().collect());
}
}
None
}
fn extract_like_glob_prefix_range<'a>(
operand: &'a Expr,
pattern: &'a Expr,
op: fsqlite_ast::LikeOp,
escape: Option<&'a Expr>,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(String, ColumnRangeBound<'a>, ColumnRangeBound<'a>)> {
let column_name = column_name(operand, table, table_alias)?;
let prefix = match op {
fsqlite_ast::LikeOp::Glob if use_builtin_like_glob_semantics() => {
extract_pure_glob_prefix(pattern)
}
fsqlite_ast::LikeOp::Like if use_builtin_like_glob_semantics() => {
extract_pure_like_prefix(pattern, escape)
.filter(|prefix| is_case_stable_like_prefix(prefix))
}
fsqlite_ast::LikeOp::Glob
| fsqlite_ast::LikeOp::Like
| fsqlite_ast::LikeOp::Match
| fsqlite_ast::LikeOp::Regexp => None,
}?;
let upper_bound = prefix_upper_bound(&prefix)?;
Some((
column_name,
owned_string_column_range_bound(prefix, true),
owned_string_column_range_bound(upper_bound, false),
))
}
fn collect_column_range_bounds<'a>(
expr: &'a Expr,
table: &TableSchema,
table_alias: Option<&str>,
target_column: &mut Option<String>,
target: &mut ColumnRangeTarget<'a>,
) -> bool {
match expr {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
collect_column_range_bounds(left, table, table_alias, target_column, target)
&& collect_column_range_bounds(right, table, table_alias, target_column, target)
}
Expr::BinaryOp {
left, op, right, ..
} => extract_column_range_bound(left, *op, right, table, table_alias).is_some_and(
|(column_name, slot, bound)| {
assign_column_range_bound(target_column, target, column_name, slot, bound)
},
),
Expr::Between {
expr: operand,
low,
high,
not: false,
..
} if is_index_range_constant(low) && is_index_range_constant(high) => {
column_name(operand, table, table_alias).is_some_and(|column_name| {
assign_column_range_bound(
target_column,
target,
column_name.clone(),
ColumnRangeSlot::Lower,
borrowed_column_range_bound(low, true),
) && assign_column_range_bound(
target_column,
target,
column_name,
ColumnRangeSlot::Upper,
borrowed_column_range_bound(high, true),
)
})
}
Expr::Like {
expr: operand,
pattern,
op,
not: false,
escape,
..
} => extract_like_glob_prefix_range(
operand,
pattern,
*op,
escape.as_deref(),
table,
table_alias,
)
.is_some_and(|(column_name, lower, upper)| {
assign_column_range_bound(
target_column,
target,
column_name.clone(),
ColumnRangeSlot::Lower,
lower,
) && assign_column_range_bound(
target_column,
target,
column_name,
ColumnRangeSlot::Upper,
upper,
)
}),
_ => false,
}
}
fn extract_column_range_bound<'a>(
left: &'a Expr,
op: fsqlite_ast::BinaryOp,
right: &'a Expr,
table: &TableSchema,
table_alias: Option<&str>,
) -> Option<(String, ColumnRangeSlot, ColumnRangeBound<'a>)> {
if let Some(column_name) = column_name(left, table, table_alias)
&& is_index_range_constant(right)
{
return match op {
fsqlite_ast::BinaryOp::Ge => Some((
column_name,
ColumnRangeSlot::Lower,
borrowed_column_range_bound(right, true),
)),
fsqlite_ast::BinaryOp::Gt => Some((
column_name,
ColumnRangeSlot::Lower,
borrowed_column_range_bound(right, false),
)),
fsqlite_ast::BinaryOp::Le => Some((
column_name,
ColumnRangeSlot::Upper,
borrowed_column_range_bound(right, true),
)),
fsqlite_ast::BinaryOp::Lt => Some((
column_name,
ColumnRangeSlot::Upper,
borrowed_column_range_bound(right, false),
)),
_ => None,
};
}
if let Some(column_name) = column_name(right, table, table_alias)
&& is_index_range_constant(left)
{
return match op {
fsqlite_ast::BinaryOp::Le => Some((
column_name,
ColumnRangeSlot::Lower,
borrowed_column_range_bound(left, true),
)),
fsqlite_ast::BinaryOp::Lt => Some((
column_name,
ColumnRangeSlot::Lower,
borrowed_column_range_bound(left, false),
)),
fsqlite_ast::BinaryOp::Ge => Some((
column_name,
ColumnRangeSlot::Upper,
borrowed_column_range_bound(left, true),
)),
fsqlite_ast::BinaryOp::Gt => Some((
column_name,
ColumnRangeSlot::Upper,
borrowed_column_range_bound(left, false),
)),
_ => None,
};
}
None
}
fn assign_column_range_bound<'a>(
target_column: &mut Option<String>,
target: &mut ColumnRangeTarget<'a>,
column_name: String,
slot: ColumnRangeSlot,
bound: ColumnRangeBound<'a>,
) -> bool {
if let Some(existing) = target_column.as_deref() {
if !existing.eq_ignore_ascii_case(&column_name) {
return false;
}
} else {
*target_column = Some(column_name);
}
let target_slot = match slot {
ColumnRangeSlot::Lower => &mut target.lower,
ColumnRangeSlot::Upper => &mut target.upper,
};
if target_slot.is_some() {
false
} else {
*target_slot = Some(bound);
true
}
}
/// Extract a column name from an expression if it's a simple column reference.
#[allow(dead_code)]
fn column_name(expr: &Expr, table: &TableSchema, table_alias: Option<&str>) -> Option<String> {
if let Expr::Column(col_ref, _) = expr {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return None;
}
if !is_rowid_ref(col_ref, Some(table), table_alias) {
return Some(col_ref.column.to_string());
}
}
None
}
/// Check if an expression is a rowid reference.
fn is_rowid_expr(expr: &Expr, table: Option<&TableSchema>, table_alias: Option<&str>) -> bool {
if let Expr::Column(col_ref, _) = expr {
if is_rowid_ref(col_ref, table, table_alias) {
return true;
}
if let Some(t) = table {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, t, table_alias)
{
return false;
}
for col in &t.columns {
if col.is_ipk && col.name.eq_ignore_ascii_case(&col_ref.column) {
return true;
}
}
}
}
false
}
fn is_rowid_ref(
col_ref: &ColumnRef,
table: Option<&TableSchema>,
table_alias: Option<&str>,
) -> bool {
if let Some(t) = table {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, t, table_alias)
{
return false;
}
if let Some(col_idx) = t.column_index(&col_ref.column) {
return t.columns[col_idx].is_ipk;
}
return t.resolves_to_hidden_rowid(&col_ref.column);
}
is_hidden_rowid_alias_name(&col_ref.column)
}
/// Extract a bind parameter index from a `?` or `?NNN` placeholder.
#[allow(dead_code)]
fn bind_param_index(expr: &Expr) -> Option<i32> {
if let Expr::Placeholder(pt, _) = expr {
match pt {
fsqlite_ast::PlaceholderType::Anonymous => Some(1),
fsqlite_ast::PlaceholderType::Numbered(n) =>
{
#[allow(clippy::cast_possible_wrap)]
Some(*n as i32)
}
_ => None,
}
} else {
None
}
}
/// Emit an expression value into a register.
///
/// For bind parameters, emits a Variable instruction.
/// Emit bytecode for an expression, placing the result in `reg`.
///
/// Cursor context for expression emission inside table scans.
///
/// When present, allows `emit_expr` to resolve `Expr::Column` references
/// by emitting `Opcode::Column` against the given cursor.
struct ScanCtx<'a> {
cursor: i32,
table: &'a TableSchema,
table_alias: Option<&'a str>,
schema: Option<&'a [TableSchema]>,
/// When set, column references are resolved by copying from registers
/// (`register_base + col_index`) instead of reading from the B-tree cursor.
/// Used for generated column expression evaluation during INSERT.
register_base: Option<i32>,
/// Secondary table contexts for UPDATE ... FROM multi-table resolution.
///
/// A single-table `UPDATE ... FROM src` carries one entry; a multi-source
/// `UPDATE ... FROM a JOIN b` / `FROM a, b` carries one entry per FROM
/// source. Column references are resolved against the primary (target)
/// table first, then each secondary in order.
secondaries: &'a [SecondaryScan<'a>],
}
/// Secondary table scan context for UPDATE ... FROM (and the register-backed
/// `excluded.*` pseudo-table threaded into UPSERT DO UPDATE subquery
/// correlation).
struct SecondaryScan<'a> {
cursor: i32,
table: &'a TableSchema,
table_alias: Option<&'a str>,
/// When set, a qualified reference to this secondary resolves by copying
/// from `register_base + col_index` instead of reading the B-tree cursor.
/// Used for the UPSERT `excluded.*` pseudo-row, whose attempted-insert
/// values live in registers (`val_regs`), not on a cursor (bd-xjfrt).
register_base: Option<i32>,
}
fn literal_integer_value(expr: &Expr) -> Option<i64> {
match expr {
Expr::Literal(Literal::Integer(value), _) => Some(*value),
_ => None,
}
}
fn try_emit_column_substr_prefix(
b: &mut ProgramBuilder,
name: &str,
arg_list: &[Expr],
reg: i32,
ctx: &ScanCtx<'_>,
) -> bool {
if !(name.eq_ignore_ascii_case("substr") || name.eq_ignore_ascii_case("substring"))
|| arg_list.len() != 3
|| ctx.register_base.is_some()
|| !use_builtin_scalar_implementation_for_codegen(name, 3)
{
return false;
}
let Expr::Column(col_ref, _) = &arg_list[0] else {
return false;
};
let Some(col_idx) = resolve_column_in_ctx(col_ref, ctx) else {
return false;
};
if ctx.table.columns.get(col_idx).is_some_and(|col| col.is_ipk)
|| literal_integer_value(&arg_list[1]) != Some(1)
{
return false;
}
let Some(prefix_len) = literal_integer_value(&arg_list[2]) else {
return false;
};
let Ok(prefix_len) = i32::try_from(prefix_len) else {
return false;
};
if prefix_len < 0 {
return false;
}
let Ok(col_idx) = i32::try_from(col_idx) else {
return false;
};
b.emit_op(
Opcode::ColumnSubstrPrefix,
ctx.cursor,
col_idx,
reg,
P4::Int(prefix_len),
0,
);
true
}
fn try_emit_column_octet_length(
b: &mut ProgramBuilder,
name: &str,
arg_list: &[Expr],
reg: i32,
ctx: &ScanCtx<'_>,
) -> bool {
if !name.eq_ignore_ascii_case("octet_length")
|| arg_list.len() != 1
|| ctx.register_base.is_some()
|| !use_builtin_scalar_implementation_for_codegen(name, 1)
{
return false;
}
let Expr::Column(col_ref, _) = &arg_list[0] else {
return false;
};
let Some(col_idx) = resolve_column_in_ctx(col_ref, ctx) else {
return false;
};
if ctx
.table
.columns
.get(col_idx)
.is_some_and(|column| column.generated_stored == Some(false))
{
return false;
}
let Ok(col_idx) = i32::try_from(col_idx) else {
return false;
};
b.emit_op(
Opcode::ColumnOctetLength,
ctx.cursor,
col_idx,
reg,
P4::None,
0,
);
true
}
enum InProbeValue<'a> {
Expr(&'a Expr),
FirstColumn,
Rowid,
}
struct InProbeSource<'a> {
table: &'a TableSchema,
table_alias: Option<&'a str>,
where_clause: Option<&'a Expr>,
value: InProbeValue<'a>,
}
#[derive(Clone, Copy)]
struct ScalarInOperandMetadata {
affinity: u8,
}
/// Recognize a scalar-subquery IN operand that native codegen can evaluate
/// once, after materializing the uncorrelated RHS.
///
/// Keep this deliberately narrower than the general scalar-subquery emitter:
/// one plain SELECT, one output value, no ordering/cardinality modifiers, and
/// no reference outside the scalar SELECT's own optional single-table scope.
/// Function calls are excluded because codegen cannot distinguish a custom
/// aggregate from a scalar override here.
fn scalar_in_operand_metadata(
operand: &Expr,
schema: &[TableSchema],
) -> Option<ScalarInOperandMetadata> {
let Expr::Subquery(select, _) = operand else {
return None;
};
if select.with.is_some()
|| !select.body.compounds.is_empty()
|| !select.order_by.is_empty()
|| select.limit.is_some()
{
return None;
}
let SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
windows,
..
} = &select.body.select
else {
return None;
};
if !group_by.is_empty()
|| having.is_some()
|| !windows.is_empty()
|| has_aggregate_columns(columns)
|| has_window_columns(columns)
|| columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. }
if in_probe_expr_requires_semantic_fallback(expr)
|| expr_contains_function_call(expr)
)
})
|| where_clause.as_deref().is_some_and(|expr| {
in_probe_expr_requires_semantic_fallback(expr)
|| expr_contains_function_call(expr)
|| is_aggregate_expr(expr)
|| expr_has_window(expr)
})
{
return None;
}
let Some(from_clause) = from.as_ref() else {
if where_clause.is_some() {
return None;
}
let [ResultColumn::Expr { expr, .. }] = columns.as_slice() else {
return None;
};
let has_column = validate_expr_columns_with(expr, &|column| {
Err(CodegenError::ColumnNotFound {
table: String::new(),
column: column.column.to_string(),
})
})
.is_err();
return (!has_column).then_some(ScalarInOperandMetadata {
affinity: expr_affinity(expr, None),
});
};
if !from_clause.joins.is_empty() {
return None;
}
let (qualified_table_name, table_alias) = match &from_clause.source {
TableOrSubquery::Table {
name,
alias,
index_hint: None,
time_travel: None,
} => (name, alias.as_deref()),
_ => return None,
};
if qualified_table_name.schema.is_some() {
return None;
}
let table = find_table(schema, &qualified_table_name.name).ok()?;
if validate_single_table_result_columns(columns, table, table_alias, None).is_err()
|| where_clause.as_deref().is_some_and(|expr| {
validate_single_table_expr_columns(expr, table, table_alias).is_err()
})
{
return None;
}
let scalar_scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
let affinity = match columns.as_slice() {
[ResultColumn::Expr { expr, .. }] => {
let resolved = resolve_column_ref(expr, table, table_alias);
resolved_expr_affinity(expr, resolved.as_ref(), &scalar_scan)
}
[ResultColumn::Star | ResultColumn::TableStar(_)] if table.columns.len() == 1 => table
.columns
.first()
.map_or(b'A', schema_column_expr_affinity),
_ => return None,
};
Some(ScalarInOperandMetadata { affinity })
}
fn emit_in_probe_value(
b: &mut ProgramBuilder,
source_cursor: i32,
probe_source: &InProbeSource<'_>,
reg: i32,
probe_scan: &ScanCtx<'_>,
) {
match probe_source.value {
InProbeValue::Expr(expr) => emit_expr(b, expr, reg, Some(probe_scan)),
InProbeValue::FirstColumn => {
// A one-column `SELECT *` still needs the centralized table-read
// semantics: an INTEGER PRIMARY KEY is stored as the rowid rather
// than in record column 0, and a VIRTUAL generated column must be
// computed instead of reading its NULL storage placeholder.
if let Some(source_base) = probe_scan.register_base {
emit_table_column_read_from_register_row(
b,
probe_source.table,
probe_source.table_alias,
probe_scan.schema,
source_base,
0,
reg,
);
} else {
emit_table_column_read(
b,
source_cursor,
probe_source.table,
probe_source.table_alias,
probe_scan.schema,
0,
reg,
);
}
}
InProbeValue::Rowid => {
b.emit_op(Opcode::Rowid, source_cursor, reg, 0, P4::None, 0);
}
}
}
fn in_probe_value_explicit_collation(probe_source: &InProbeSource<'_>) -> Option<String> {
match probe_source.value {
InProbeValue::Expr(expr) => extract_collation(expr).map(str::to_owned),
InProbeValue::FirstColumn | InProbeValue::Rowid => None,
}
}
fn in_probe_value_effective_collation(
probe_source: &InProbeSource<'_>,
probe_scan: &ScanCtx<'_>,
) -> Option<String> {
match probe_source.value {
InProbeValue::Rowid => None,
InProbeValue::FirstColumn => probe_source
.table
.columns
.first()
.and_then(|column| column.collation.clone()),
InProbeValue::Expr(expr) => {
effective_collation_ctx(expr, Some(probe_scan)).map(str::to_owned)
}
}
}
fn in_probe_value_affinity(probe_source: &InProbeSource<'_>, probe_scan: &ScanCtx<'_>) -> u8 {
match probe_source.value {
InProbeValue::Rowid => b'D',
InProbeValue::FirstColumn => probe_source
.table
.columns
.first()
.map_or(b'A', schema_column_expr_affinity),
InProbeValue::Expr(expr) => expr_affinity(expr, Some(probe_scan)),
}
}
/// Resolve the collation for `lhs IN (SELECT rhs ...)` with SQLite's
/// comparison precedence: explicit COLLATE on either side (left wins a tie),
/// then a declared column collation (again left before right).
fn in_probe_comparison_collation(
operand: &Expr,
operand_ctx: &ScanCtx<'_>,
probe_source: &InProbeSource<'_>,
probe_scan: &ScanCtx<'_>,
) -> Option<String> {
extract_collation(operand)
.map(str::to_owned)
.or_else(|| in_probe_value_explicit_collation(probe_source))
.or_else(|| effective_collation_ctx(operand, Some(operand_ctx)).map(str::to_owned))
.or_else(|| in_probe_value_effective_collation(probe_source, probe_scan))
}
fn probe_source_value_is_unique(probe_source: &InProbeSource<'_>) -> bool {
match probe_source.value {
InProbeValue::Rowid => true,
InProbeValue::FirstColumn => probe_source.table.columns.first().is_some_and(|column| {
column.is_ipk
|| column.unique
|| probe_source
.table
.index_for_column(&column.name)
.is_some_and(|idx| idx.is_unique && idx.key_term_count() == 1)
}),
InProbeValue::Expr(expr) => {
let Expr::Column(col_ref, _) = expr else {
return false;
};
if is_rowid_ref(col_ref, Some(probe_source.table), probe_source.table_alias) {
return true;
}
probe_source
.table
.column_index(&col_ref.column)
.and_then(|idx| probe_source.table.columns.get(idx))
.is_some_and(|column| {
column.is_ipk
|| column.unique
|| probe_source
.table
.index_for_column(&column.name)
.is_some_and(|idx| idx.is_unique && idx.key_term_count() == 1)
})
}
}
}
fn count_probe_source_can_skip_materialization(probe_source: &InProbeSource<'_>) -> bool {
probe_source_value_is_unique(probe_source)
}
fn can_use_direct_count_indexed_in_subquery_probe_source(
table: &TableSchema,
idx_schema: &IndexSchema,
probe_source: &InProbeSource<'_>,
operand: &Expr,
scan_ctx: &ScanCtx<'_>,
) -> bool {
matches!(probe_source.value, InProbeValue::Rowid)
&& can_use_once_materialized_in_probe_source(probe_source, operand, scan_ctx)
&& count_probe_source_can_skip_materialization(probe_source)
&& count_exists_semijoin_merge_is_safe(table, idx_schema, probe_source)
}
/// Whether generic expression lowering would silently lose semantics needed by
/// an IN-subquery probe.
///
/// `emit_expr` does not lower row values or RAISE, and its scalar-function arm
/// deliberately ignores aggregate-only FILTER / in-call ORDER BY modifiers.
/// Nested SELECT scopes are also outside these single-table probe helpers.
/// Decline all of those shapes so connection-level semantic evaluation can
/// either execute them correctly or report SQLite's preparation error.
fn in_probe_expr_requires_semantic_fallback(expr: &Expr) -> bool {
match expr {
Expr::RowValue(..) | Expr::Raise { .. } | Expr::Exists { .. } | Expr::Subquery(..) => true,
Expr::BinaryOp { left, right, .. }
| Expr::JsonAccess {
expr: left,
path: right,
..
} => {
in_probe_expr_requires_semantic_fallback(left)
|| in_probe_expr_requires_semantic_fallback(right)
}
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => in_probe_expr_requires_semantic_fallback(expr),
Expr::Between {
expr, low, high, ..
} => {
in_probe_expr_requires_semantic_fallback(expr)
|| in_probe_expr_requires_semantic_fallback(low)
|| in_probe_expr_requires_semantic_fallback(high)
}
Expr::In { expr, set, .. } => {
in_probe_expr_requires_semantic_fallback(expr)
|| match set {
InSet::List(values) => {
values.iter().any(in_probe_expr_requires_semantic_fallback)
}
InSet::Table(_) | InSet::Subquery(_) => true,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
in_probe_expr_requires_semantic_fallback(expr)
|| in_probe_expr_requires_semantic_fallback(pattern)
|| escape
.as_deref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
|| whens.iter().any(|(when_expr, then_expr)| {
in_probe_expr_requires_semantic_fallback(when_expr)
|| in_probe_expr_requires_semantic_fallback(then_expr)
})
|| else_expr
.as_deref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
!order_by.is_empty()
|| filter.is_some()
|| over.is_some()
|| matches!(
args,
FunctionArgs::List(values)
if values
.iter()
.any(in_probe_expr_requires_semantic_fallback)
)
}
Expr::Literal(..)
| Expr::BoundOuterValue { .. }
| Expr::Column(..)
| Expr::Placeholder(..) => false,
}
}
fn in_probe_result_columns_require_semantic_fallback(columns: &[ResultColumn]) -> bool {
columns.iter().any(|column| {
matches!(
column,
ResultColumn::Expr { expr, .. }
if in_probe_expr_requires_semantic_fallback(expr)
)
})
}
fn resolve_in_probe_source<'a>(
set: &'a fsqlite_ast::InSet,
schema: &'a [TableSchema],
) -> Option<InProbeSource<'a>> {
match set {
fsqlite_ast::InSet::List(_) => None,
fsqlite_ast::InSet::Table(name) => {
if name.schema.is_some() {
return None;
}
let table = find_table(schema, &name.name).ok()?;
if table.columns.len() != 1 {
return None;
}
Some(InProbeSource {
table,
table_alias: None,
where_clause: None,
value: InProbeValue::FirstColumn,
})
}
fsqlite_ast::InSet::Subquery(subquery) => {
if subquery.with.is_some()
|| !subquery.body.compounds.is_empty()
|| !subquery.order_by.is_empty()
|| subquery.limit.is_some()
{
return None;
}
let fsqlite_ast::SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
windows,
..
} = &subquery.body.select
else {
return None;
};
if has_aggregate_columns(columns)
|| has_window_columns(columns)
|| !group_by.is_empty()
|| having.is_some()
|| !windows.is_empty()
|| in_probe_result_columns_require_semantic_fallback(columns)
|| where_clause
.as_deref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
{
return None;
}
let from_clause = from.as_ref()?;
if !from_clause.joins.is_empty() {
return None;
}
let (qualified_table_name, table_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table {
name,
alias,
index_hint: None,
time_travel: None,
} => (name, alias.as_deref()),
_ => return None,
};
if qualified_table_name.schema.is_some() {
return None;
}
let table = find_table(schema, &qualified_table_name.name).ok()?;
if validate_single_table_result_columns(columns, table, table_alias, None).is_err()
|| where_clause.as_deref().is_some_and(|expr| {
validate_single_table_expr_columns(expr, table, table_alias).is_err()
})
{
return None;
}
let value = match columns.as_slice() {
[fsqlite_ast::ResultColumn::Expr { expr, .. }] => {
if is_rowid_expr(expr, Some(table), table_alias) {
InProbeValue::Rowid
} else {
InProbeValue::Expr(expr)
}
}
[fsqlite_ast::ResultColumn::Star | fsqlite_ast::ResultColumn::TableStar(_)] => {
if table.columns.len() != 1 {
return None;
}
InProbeValue::FirstColumn
}
_ => return None,
};
Some(InProbeSource {
table,
table_alias,
where_clause: where_clause.as_deref(),
value,
})
}
}
}
/// Attempt to emit bytecode for a complex IN subquery with ORDER BY and/or LIMIT.
///
/// Returns `true` if the subquery was handled, `false` if it cannot be handled
/// and must be routed through connection-level semantic evaluation. Direct
/// callers fail closed at the caller rather than synthesizing a SQL value.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_lines
)]
fn try_emit_complex_in_subquery(
b: &mut ProgramBuilder,
operand: &Expr,
subquery: &SelectStatement,
not: bool,
reg: i32,
scan_ctx: &ScanCtx<'_>,
) -> bool {
let Some(schema) = scan_ctx.schema else {
return false;
};
let scalar_operand = scalar_in_operand_metadata(operand, schema);
if in_probe_expr_requires_semantic_fallback(operand) && scalar_operand.is_none() {
return false;
}
// Reject WITH and compound queries.
if subquery.with.is_some() || !subquery.body.compounds.is_empty() {
return false;
}
let fsqlite_ast::SelectCore::Select {
distinct,
columns,
from,
where_clause,
group_by,
having,
windows,
..
} = &subquery.body.select
else {
return false;
};
// Per-row IN probing cannot reproduce aggregate or window-query
// cardinality. Decline those shapes, including implicit aggregate/window
// queries without an explicit GROUP BY or named WINDOW clause.
let where_has_aggregate_or_window = where_clause
.as_deref()
.is_some_and(|expr| is_aggregate_expr(expr) || expr_has_window(expr));
let order_has_aggregate_or_window = subquery
.order_by
.iter()
.any(|term| is_aggregate_expr(&term.expr) || expr_has_window(&term.expr));
let limit_has_aggregate_or_window = subquery.limit.as_ref().is_some_and(|clause| {
is_aggregate_expr(&clause.limit)
|| expr_has_window(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(|expr| is_aggregate_expr(expr) || expr_has_window(expr))
});
if has_aggregate_columns(columns)
|| has_window_columns(columns)
|| where_has_aggregate_or_window
|| order_has_aggregate_or_window
|| limit_has_aggregate_or_window
|| !group_by.is_empty()
|| having.is_some()
|| !windows.is_empty()
|| in_probe_result_columns_require_semantic_fallback(columns)
|| where_clause
.as_deref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
|| subquery
.order_by
.iter()
.any(|term| in_probe_expr_requires_semantic_fallback(&term.expr))
|| subquery.limit.as_ref().is_some_and(|clause| {
in_probe_expr_requires_semantic_fallback(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(in_probe_expr_requires_semantic_fallback)
})
{
return false;
}
let Some(from_clause) = from.as_ref() else {
return false;
};
// Reject JOINs.
if !from_clause.joins.is_empty() {
return false;
}
let (qualified_table_name, table_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table {
name,
alias,
index_hint: None,
time_travel: None,
} => (name, alias.as_deref()),
_ => return false,
};
if qualified_table_name.schema.is_some() {
return false;
}
let Ok(table) = find_table(schema, &qualified_table_name.name) else {
return false;
};
// WHERE references and aliases nested inside ORDER expressions fall back
// to a SELECT alias only when no real source column has that name. Exact
// ORDER aliases/ordinals have output-column precedence and are resolved
// separately below. Reuse the general rewrite for the former cases so
// unknown names never degrade into emitted NULL values.
let where_clause_rewritten = where_clause
.as_deref()
.map(|expr| rewrite_having_select_aliases(expr, columns, table));
let where_clause = where_clause_rewritten.as_ref();
let order_by_exprs: Vec<Expr> = subquery
.order_by
.iter()
.map(|term| rewrite_having_select_aliases(&term.expr, columns, table))
.collect();
if validate_single_table_result_columns(columns, table, table_alias, None).is_err()
|| where_clause.is_some_and(|expr| {
validate_single_table_expr_columns(expr, table, table_alias).is_err()
})
|| order_by_exprs
.iter()
.any(|expr| validate_single_table_expr_columns(expr, table, table_alias).is_err())
{
return false;
}
// This helper accepts exactly one output column. Integer ORDER BY terms
// are ordinals, so anything other than 1 is an out-of-range error rather
// than a constant sort expression.
if subquery
.order_by
.iter()
.any(|term| order_by_integer_ordinal(&term.expr).is_some_and(|ordinal| ordinal != 1))
{
return false;
}
if subquery.limit.as_ref().is_some_and(|clause| {
let has_unsupported_reference = |expr: &Expr| {
expr_contains_nested_subquery(expr)
|| validate_expr_columns_with(expr, &|column| {
Err(CodegenError::ColumnNotFound {
table: table.name.clone(),
column: column.column.to_string(),
})
})
.is_err()
};
has_unsupported_reference(&clause.limit)
|| clause
.offset
.as_ref()
.is_some_and(has_unsupported_reference)
}) {
return false;
}
// Determine the value expression to compare. `SELECT *` is a legal
// single-column IN source only when the source table itself has one column.
let value = match columns.as_slice() {
[fsqlite_ast::ResultColumn::Expr { expr, .. }] => {
if is_rowid_expr(expr, Some(table), table_alias) {
InProbeValue::Rowid
} else {
InProbeValue::Expr(expr)
}
}
[fsqlite_ast::ResultColumn::Star | fsqlite_ast::ResultColumn::TableStar(_)] => {
if table.columns.len() != 1 {
return false;
}
InProbeValue::FirstColumn
}
_ => return false,
};
let has_order_by = !subquery.order_by.is_empty();
let is_distinct = *distinct == Distinctness::Distinct;
// The selected RHS is retained for the lifetime of the statement, so each
// complex IN expression needs its own stable cursor range.
let cursor_base = b.alloc_aux_cursor_range(4);
let subq_cursor = cursor_base;
let sorter_cursor = cursor_base + 1;
let distinct_cursor = cursor_base + 2;
let membership_cursor = cursor_base + 3;
let subq_scan = ScanCtx {
cursor: subq_cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
let probe_source = InProbeSource {
table,
table_alias,
where_clause,
value,
};
// Connection-level compilation canonicalizes all parameters to explicit
// slots. Raw codegen callers cannot safely use emission-order numbering
// here because LIMIT is intentionally evaluated before WHERE/ORDER BY.
if expr_contains_non_numbered_placeholder(operand)
|| select_contains_non_numbered_placeholder(subquery)
{
return false;
}
// This implementation materializes an uncorrelated RHS once per statement.
// Decline correlated references instead of accidentally resolving them
// against the RHS table cursor.
let sort_keys: Vec<SortKeySource> = order_by_exprs
.iter()
.zip(subquery.order_by.iter())
.map(|(resolved_expr, term)| {
if order_by_refers_to_single_star_output(&term.expr, columns) {
SortKeySource::Column(0)
} else if let Some(output_expr) =
resolve_single_output_order_expr(&term.expr, columns, table, table_alias)
{
// Exact output aliases/ordinals win over a same-named source
// column in ORDER BY. Resolve the projected expression without
// re-applying SELECT-list alias lookup.
resolve_sort_key(output_expr, table, table_alias, &[])
} else {
resolve_sort_key(resolved_expr, table, table_alias, columns)
}
})
.collect();
let order_by_references_outer = sort_keys.iter().any(|key| {
matches!(
key,
SortKeySource::Expression(expr)
if probe_expr_references_outer_scan(expr, &probe_source, scan_ctx)
)
});
let limit_references_outer = subquery.limit.as_ref().is_some_and(|clause| {
probe_expr_references_outer_scan(&clause.limit, &probe_source, scan_ctx)
|| clause.offset.as_ref().is_some_and(|offset| {
probe_expr_references_outer_scan(offset, &probe_source, scan_ctx)
})
});
if in_probe_source_references_outer_scan(&probe_source, scan_ctx)
|| order_by_references_outer
|| limit_references_outer
{
return false;
}
let order_output_slots: Vec<Option<usize>> = subquery
.order_by
.iter()
.map(|term| resolve_order_by_output_slot(&term.expr, columns, table, table_alias))
.collect();
let sort_order: String = subquery
.order_by
.iter()
.map(|term| {
let is_desc = term.direction == Some(SortDirection::Desc);
let nulls_last = match term.nulls {
Some(NullsOrder::Last) => true,
Some(NullsOrder::First) => false,
None => is_desc,
};
match (is_desc, nulls_last) {
(false, false) => '+',
(false, true) => '>',
(true, true) => '-',
(true, false) => '<',
}
})
.collect();
let sort_collations: Vec<String> = sort_keys
.iter()
.zip(subquery.order_by.iter())
.enumerate()
.map(|(index, (key, term))| {
if let Some(collation) = extract_collation(&term.expr) {
return collation.to_owned();
}
if let Some(output_slot) = order_output_slots[index]
&& let Some(collation) =
result_output_slot_collation(output_slot, columns, table, table_alias)
{
return collation;
}
match key {
SortKeySource::Column(index) => table
.columns
.get(*index)
.and_then(|column| column.collation.as_deref())
.unwrap_or_default()
.to_owned(),
SortKeySource::Expression(expr) => effective_collation_ctx(expr, Some(&subq_scan))
.unwrap_or_default()
.to_owned(),
SortKeySource::Rowid => String::new(),
}
})
.collect();
let distinct_projection_mode = ordered_distinct_projection_mode(
*distinct,
&subquery.order_by,
&order_output_slots,
&sort_collations,
columns,
table,
table_alias,
);
let keep_subq_cursor_open = has_order_by
&& distinct_projection_mode == OrderedDistinctProjectionMode::ReprojectRepresentative
&& !table.without_rowid;
let probe_aff = in_probe_value_affinity(&probe_source, &subq_scan);
let operand_affinity = scalar_operand.map_or_else(
|| expr_affinity(operand, Some(scan_ctx)),
|metadata| metadata.affinity,
);
let comparison_affinity = combine_comparison_affinity(operand_affinity, probe_aff);
let comparison_affinity_string = u8::try_from(comparison_affinity)
.ok()
.filter(|&code| code != 0)
.map(|code| (code as char).to_string());
let probe_collation = in_probe_value_effective_collation(&probe_source, &subq_scan);
let comparison_collation =
in_probe_comparison_collation(operand, scan_ctx, &probe_source, &subq_scan);
// Reserve the operand register now, but evaluate it only after the
// uncorrelated RHS has been materialized (SQLite's observable order for
// volatile functions and errors).
let r_operand = if scalar_operand.is_some() {
// The Once-guarded scalar result must survive later result-expression
// code and subsequent outer rows, so it cannot use the temp pool.
b.alloc_reg()
} else {
b.alloc_temp()
};
let matched_label = b.emit_label();
let null_result_label = b.emit_label();
let done_label = b.emit_label();
// Materialize the final RHS membership set exactly once. Besides avoiding
// an O(outer rows × RHS rows) rebuild, this prevents an uncorrelated RHS
// from being reevaluated separately for every outer row.
let r_rhs_nonempty = b.alloc_reg();
let r_rhs_saw_null = b.alloc_reg();
let build_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, build_done, P4::None, 0);
b.emit_op(Opcode::Integer, 0, r_rhs_nonempty, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, r_rhs_saw_null, 0, P4::None, 0);
b.emit_op(
Opcode::OpenAutoindex,
membership_cursor,
1,
0,
comparison_collation
.as_ref()
.map_or(P4::None, |collation| P4::Collation(collation.clone())),
0,
);
// SQLite validates LIMIT before OFFSET and skips OFFSET evaluation for
// LIMIT 0. It also performs this validation before opening the RHS table,
// while applying both counters after WHERE/DISTINCT and after ORDER BY.
// Keep this inside the Once-guarded build so a reused complex IN expression
// does not reevaluate a dynamic LIMIT for every outer row.
let selected_done = b.emit_label();
let (limit_reg, offset_reg) =
emit_limit_offset_registers(b, subquery.limit.as_ref(), selected_done);
let top_n_bound_reg = if has_order_by && limit_clause_can_enable_top_n(subquery.limit.as_ref())
{
emit_top_n_bound_register(b, limit_reg, offset_reg)
} else {
None
};
if has_order_by {
// Build the same sort metadata as a top-level ordered SELECT.
let sorter_p4 = if sort_collations
.iter()
.any(|collation| !collation.is_empty())
{
format!("{sort_order}|{}", sort_collations.join(","))
} else {
sort_order
};
let num_sort_keys = sort_keys.len();
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
num_sort_keys as i32,
top_n_bound_reg.unwrap_or(0),
P4::Str(sorter_p4),
top_n_bound_reg.map_or(0, |_| SORTER_OPEN_TOP_N_REGISTER),
);
}
if is_distinct {
b.emit_op(
Opcode::OpenAutoindex,
distinct_cursor,
1,
0,
probe_collation.map_or(P4::None, |collation| P4::Collation(collation.clone())),
0,
);
}
b.emit_op(
Opcode::OpenRead,
subq_cursor,
table.root_page,
0,
P4::Table(table.name.clone()),
0,
);
if has_order_by {
// === Pass 1: scan, DISTINCT-filter, and materialize into the sorter. ===
let scan_start = b.current_addr();
let scan_done = b.emit_label();
b.emit_jump_to_label(Opcode::Rewind, subq_cursor, 0, scan_done, P4::None, 0);
let next_source_row = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
subq_cursor,
table,
table_alias,
schema,
next_source_row,
);
}
let num_sort_keys = sort_keys.len();
let stored_data_cols = if distinct_projection_mode
== OrderedDistinctProjectionMode::ReprojectRepresentative
&& table.without_rowid
{
table.columns.len()
} else {
1
};
let num_sorter_cols = num_sort_keys + stored_data_cols;
let sorter_base = b.alloc_regs(num_sorter_cols as i32);
let stored_data_base = sorter_base + num_sort_keys as i32;
let value_reg =
if distinct_projection_mode == OrderedDistinctProjectionMode::ReprojectRepresentative {
b.alloc_reg()
} else {
stored_data_base
};
// DISTINCT is defined on the result value, so evaluate and deduplicate
// it before any ORDER BY-only expressions. A non-DISTINCT top-N query,
// by contrast, evaluates independent sort keys before the projected
// value; this preserves the selected value when both are volatile.
if is_distinct {
emit_in_probe_value(b, subq_cursor, &probe_source, value_reg, &subq_scan);
let distinct_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
value_reg,
1,
distinct_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::Found,
distinct_cursor,
distinct_record,
next_source_row,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
distinct_cursor,
distinct_record,
0,
P4::None,
0,
);
b.free_temp(distinct_record);
}
let mut value_emitted = is_distinct;
for (index, (register, key)) in (sorter_base..).zip(sort_keys.iter()).enumerate() {
let order_uses_output_value = resolve_single_output_order_expr(
&subquery.order_by[index].expr,
columns,
table,
table_alias,
)
.is_some()
|| order_by_refers_to_single_star_output(&subquery.order_by[index].expr, columns);
if order_uses_output_value {
if !value_emitted {
// Exact output aliases, ordinals, and structurally equal
// expressions share the projected value in a
// non-DISTINCT ordered SELECT. Evaluate it lazily at this
// key's position so earlier independent keys retain
// SQLite's observable evaluation order.
emit_in_probe_value(b, subq_cursor, &probe_source, value_reg, &subq_scan);
value_emitted = true;
}
b.emit_op(Opcode::Copy, value_reg, register, 0, P4::None, 0);
} else {
emit_resolved_column(b, key, subq_cursor, register, &subq_scan);
}
}
if top_n_bound_reg.is_some() {
let key_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
sorter_base,
num_sort_keys as i32,
key_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SorterCompare,
sorter_cursor,
key_record,
next_source_row,
P4::None,
SORTER_COMPARE_TOP_N_PREFLIGHT,
);
b.free_temp(key_record);
}
if !value_emitted {
emit_in_probe_value(b, subq_cursor, &probe_source, value_reg, &subq_scan);
}
if distinct_projection_mode == OrderedDistinctProjectionMode::ReprojectRepresentative {
emit_ordered_distinct_source_state(b, subq_cursor, table, stored_data_base);
}
let record_reg = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
sorter_base,
num_sorter_cols as i32,
record_reg,
P4::None,
0,
);
b.emit_op(
Opcode::SorterInsert,
sorter_cursor,
record_reg,
0,
P4::None,
0,
);
b.free_temp(record_reg);
b.resolve_label(next_source_row);
let scan_body = (scan_start + 1) as i32;
b.emit_op(Opcode::Next, subq_cursor, scan_body, 0, P4::None, 0);
b.resolve_label(scan_done);
if !keep_subq_cursor_open {
b.emit_op(Opcode::Close, subq_cursor, 0, 0, P4::None, 0);
}
// === Pass 2: apply OFFSET/LIMIT to sorted, distinct RHS values. ===
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
selected_done,
P4::None,
0,
);
let probe_loop = b.current_addr();
let next_sorted_row = b.emit_label();
if let Some(offset) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, offset, 1, next_sorted_row, P4::None, 0);
}
let sorted_reg = b.alloc_temp();
b.emit_op(
Opcode::SorterData,
sorter_cursor,
sorted_reg,
0,
P4::None,
0,
);
let r_probe = b.alloc_temp();
match distinct_projection_mode {
OrderedDistinctProjectionMode::StoredOutput => {
b.emit_op(
Opcode::Column,
sorter_cursor,
num_sort_keys as i32,
r_probe,
P4::None,
0,
);
}
OrderedDistinctProjectionMode::ReprojectRepresentative if table.without_rowid => {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let source_base = b.alloc_regs(table.columns.len() as i32);
for column_index in 0..table.columns.len() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
sorter_cursor,
(num_sort_keys + column_index) as i32,
source_base + column_index as i32,
P4::None,
0,
);
}
let replay_scan = ScanCtx {
cursor: 0,
table,
table_alias,
schema: Some(schema),
register_base: Some(source_base),
secondaries: &[],
};
emit_in_probe_value(b, 0, &probe_source, r_probe, &replay_scan);
}
OrderedDistinctProjectionMode::ReprojectRepresentative => {
let representative_rowid = b.alloc_temp();
b.emit_op(
Opcode::Column,
sorter_cursor,
num_sort_keys as i32,
representative_rowid,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::SeekRowid,
subq_cursor,
representative_rowid,
next_sorted_row,
P4::None,
0,
);
b.free_temp(representative_rowid);
emit_in_probe_value(b, subq_cursor, &probe_source, r_probe, &subq_scan);
}
}
b.emit_op(Opcode::Integer, 1, r_rhs_nonempty, 0, P4::None, 0);
if let Some(ref affinity) = comparison_affinity_string {
b.emit_op(
Opcode::Affinity,
r_probe,
1,
0,
P4::Affinity(affinity.clone()),
0,
);
}
let saw_null = b.emit_label();
let value_materialized = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_probe, 0, saw_null, P4::None, 0);
let membership_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
r_probe,
1,
membership_record,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
membership_cursor,
membership_record,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, value_materialized, P4::None, 0);
b.resolve_label(saw_null);
b.emit_op(Opcode::Integer, 1, r_rhs_saw_null, 0, P4::None, 0);
b.resolve_label(value_materialized);
b.free_temp(membership_record);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, selected_done, P4::None, 0);
}
b.resolve_label(next_sorted_row);
b.emit_op(
Opcode::SorterNext,
sorter_cursor,
probe_loop as i32,
0,
P4::None,
0,
);
b.free_temp(r_probe);
b.free_temp(sorted_reg);
} else {
// A LIMIT-only subquery must preserve the source scan order. Applying a
// value-keyed sorter here would silently reorder rows before LIMIT.
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, subq_cursor, 0, selected_done, P4::None, 0);
let next_source_row = b.emit_label();
if let Some(where_expr) = where_clause {
emit_where_filter(
b,
where_expr,
subq_cursor,
table,
table_alias,
schema,
next_source_row,
);
}
// Without DISTINCT, OFFSET discards source rows before their SELECT
// expression is evaluated. This avoids invoking volatile/erroring
// projections for skipped rows. DISTINCT must still evaluate and
// deduplicate the value before applying OFFSET.
if !is_distinct && let Some(offset) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, offset, 1, next_source_row, P4::None, 0);
}
let r_probe = b.alloc_temp();
emit_in_probe_value(b, subq_cursor, &probe_source, r_probe, &subq_scan);
if is_distinct {
let distinct_record = b.alloc_temp();
b.emit_op(Opcode::MakeRecord, r_probe, 1, distinct_record, P4::None, 0);
b.emit_jump_to_label(
Opcode::Found,
distinct_cursor,
distinct_record,
next_source_row,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
distinct_cursor,
distinct_record,
0,
P4::None,
0,
);
b.free_temp(distinct_record);
if let Some(offset) = offset_reg {
b.emit_jump_to_label(Opcode::IfPos, offset, 1, next_source_row, P4::None, 0);
}
}
b.emit_op(Opcode::Integer, 1, r_rhs_nonempty, 0, P4::None, 0);
if let Some(ref affinity) = comparison_affinity_string {
b.emit_op(
Opcode::Affinity,
r_probe,
1,
0,
P4::Affinity(affinity.clone()),
0,
);
}
let saw_null = b.emit_label();
let value_materialized = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_probe, 0, saw_null, P4::None, 0);
let membership_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
r_probe,
1,
membership_record,
P4::None,
0,
);
b.emit_op(
Opcode::IdxInsert,
membership_cursor,
membership_record,
0,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, value_materialized, P4::None, 0);
b.resolve_label(saw_null);
b.emit_op(Opcode::Integer, 1, r_rhs_saw_null, 0, P4::None, 0);
b.resolve_label(value_materialized);
b.free_temp(membership_record);
if let Some(lim_r) = limit_reg {
b.emit_jump_to_label(Opcode::DecrJumpZero, lim_r, 0, selected_done, P4::None, 0);
}
b.resolve_label(next_source_row);
let loop_body = (loop_start + 1) as i32;
b.emit_op(Opcode::Next, subq_cursor, loop_body, 0, P4::None, 0);
b.free_temp(r_probe);
}
b.resolve_label(selected_done);
if has_order_by {
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
}
if is_distinct {
b.emit_op(Opcode::Close, distinct_cursor, 0, 0, P4::None, 0);
}
if !has_order_by || keep_subq_cursor_open {
b.emit_op(Opcode::Close, subq_cursor, 0, 0, P4::None, 0);
}
b.resolve_label(build_done);
if scalar_operand.is_some() {
let scalar_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, scalar_done, P4::None, 0);
emit_expr(b, operand, r_operand, Some(scan_ctx));
b.resolve_label(scalar_done);
} else {
emit_expr(b, operand, r_operand, Some(scan_ctx));
}
// Probe the once-built, non-NULL membership index for this outer row.
// Keep the explicit empty-set branch separate: NULL IN (empty) is false,
// while NULL IN (any non-empty set) is unknown.
let operand_null_label = b.emit_label();
let no_match_label = b.emit_label();
b.emit_jump_to_label(
Opcode::IsNull,
r_operand,
0,
operand_null_label,
P4::None,
0,
);
if let Some(ref affinity) = comparison_affinity_string {
b.emit_op(
Opcode::Affinity,
r_operand,
1,
0,
P4::Affinity(affinity.clone()),
0,
);
}
let operand_record = b.alloc_temp();
b.emit_op(
Opcode::MakeRecord,
r_operand,
1,
operand_record,
P4::None,
0,
);
b.emit_jump_to_label(
Opcode::Found,
membership_cursor,
operand_record,
matched_label,
P4::None,
0,
);
b.free_temp(operand_record);
b.emit_jump_to_label(
Opcode::If,
r_rhs_saw_null,
0,
null_result_label,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, no_match_label, P4::None, 0);
b.resolve_label(operand_null_label);
b.emit_jump_to_label(
Opcode::If,
r_rhs_nonempty,
0,
null_result_label,
P4::None,
0,
);
b.resolve_label(no_match_label);
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(matched_label);
b.emit_op(Opcode::Integer, i32::from(!not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_result_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
if scalar_operand.is_none() {
b.free_temp(r_operand);
}
true
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_in_probe_expr(
b: &mut ProgramBuilder,
operand: &Expr,
set: &fsqlite_ast::InSet,
not: bool,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
let Some(scan_ctx) = ctx else {
emit_in_probe_codegen_failure(b, "missing scan context");
return;
};
let Some(schema) = scan_ctx.schema else {
emit_in_probe_codegen_failure(b, "missing schema");
return;
};
let Some(probe_source) = resolve_in_probe_source(set, schema) else {
// Try to handle complex subqueries with ORDER BY/LIMIT.
if let fsqlite_ast::InSet::Subquery(subquery) = set
&& try_emit_complex_in_subquery(b, operand, subquery, not, reg, scan_ctx)
{
return;
}
emit_in_probe_codegen_failure(b, "unsupported probe source");
return;
};
if in_probe_expr_requires_semantic_fallback(operand) {
emit_in_probe_codegen_failure(b, "unsupported probe operand");
return;
}
if in_probe_source_references_outer_scan(&probe_source, scan_ctx) {
emit_in_probe_codegen_failure(b, "correlated probe source requires outer substitution");
return;
}
if can_use_once_materialized_in_probe_source(&probe_source, operand, scan_ctx) {
emit_once_materialized_in_probe_source(
b,
operand,
&probe_source,
not,
reg,
scan_ctx,
schema,
);
return;
}
// Keep probe cursors far from primary scan/sorter cursors used by main paths.
let probe_cursor = scan_ctx.cursor + 64;
let r_operand = b.alloc_temp();
let r_probe = b.alloc_temp();
emit_expr(b, operand, r_operand, Some(scan_ctx));
let null_label = b.emit_label();
let no_match_label = b.emit_label();
let matched_label = b.emit_label();
let done_label = b.emit_label();
b.emit_op(
Opcode::OpenRead,
probe_cursor,
probe_source.table.root_page,
0,
P4::Table(probe_source.table.name.clone()),
0,
);
// Track whether any subquery row value is NULL (for three-valued IN).
let r_saw_null = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
let loop_start = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, probe_cursor, 0, no_match_label, P4::None, 0);
let skip_label = probe_source.where_clause.map(|_| b.emit_label());
if let (Some(where_expr), Some(skip)) = (probe_source.where_clause, skip_label) {
emit_where_filter(
b,
where_expr,
probe_cursor,
probe_source.table,
probe_source.table_alias,
schema,
skip,
);
}
let probe_scan = ScanCtx {
cursor: probe_cursor,
table: probe_source.table,
table_alias: probe_source.table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
emit_in_probe_value(b, probe_cursor, &probe_source, r_probe, &probe_scan);
// Apply the comparison affinity between the outer operand and the subquery
// probe column, mirroring `=`/value-list IN coercion (bd-56aj2 IN-subquery).
let probe_aff = in_probe_value_affinity(&probe_source, &probe_scan);
let probe_aff_p5 =
combine_comparison_affinity(expr_affinity(operand, Some(scan_ctx)), probe_aff);
let comparison_collation =
in_probe_comparison_collation(operand, scan_ctx, &probe_source, &probe_scan)
.map_or(P4::None, P4::Collation);
b.emit_jump_to_label(
Opcode::Eq,
r_probe,
r_operand,
matched_label,
comparison_collation,
probe_aff_p5,
);
// A NULL on either side makes a no-match unknown, but only after at least
// one qualifying RHS row exists. An empty RHS remains a definite miss.
let after_flag = b.emit_label();
let set_flag = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, set_flag, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_probe, 0, set_flag, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, after_flag, P4::None, 0);
b.resolve_label(set_flag);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(after_flag);
if let Some(skip) = skip_label {
b.resolve_label(skip);
}
let loop_body = (loop_start + 1) as i32;
b.emit_op(Opcode::Next, probe_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(no_match_label);
// No match. If any subquery value was NULL → result is NULL.
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(matched_label);
b.emit_op(Opcode::Integer, i32::from(!not), reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, probe_cursor, 0, 0, P4::None, 0);
b.free_temp(r_saw_null);
b.free_temp(r_probe);
b.free_temp(r_operand);
}
/// Fail closed when routing and expression lowering disagree about an `IN`
/// probe. Returning SQL `NULL` here would silently turn an internal codegen
/// defect into a query result.
fn emit_in_probe_codegen_failure(b: &mut ProgramBuilder, reason: &str) {
b.emit_op(
Opcode::Halt,
ErrorCode::Internal as i32,
0,
0,
P4::Str(format!("IN probe codegen invariant failed: {reason}")),
0,
);
}
/// Lower a materialized row-value `IN` list without collapsing the row into a
/// scalar register.
///
/// SQLite compares each candidate tuple field by field. A definite inequality
/// rejects that candidate even if an earlier field comparison was unknown;
/// only a candidate with no unequal field and at least one NULL contributes an
/// UNKNOWN result. The final syntactic RHS tuple supplies comparison affinity
/// and collation metadata for every candidate, matching SQLite's vector-IN
/// expression metadata rules.
fn emit_row_value_in_list(
b: &mut ProgramBuilder,
lhs_exprs: &[Expr],
values: &[Expr],
not: bool,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
if lhs_exprs.is_empty() {
emit_in_probe_codegen_failure(b, "row-value operand has no fields");
return;
}
let mut rhs_rows = Vec::with_capacity(values.len());
for value in values {
let Expr::RowValue(rhs_exprs, _) = value else {
emit_in_probe_codegen_failure(b, "row-value IN list contains a scalar candidate");
return;
};
if rhs_exprs.len() != lhs_exprs.len() {
emit_in_probe_codegen_failure(
b,
&format!(
"row-value IN arity mismatch: expected {}, found {}",
lhs_exprs.len(),
rhs_exprs.len()
),
);
return;
}
rhs_rows.push(rhs_exprs.as_slice());
}
let Some(donor_exprs) = rhs_rows.last().copied() else {
// The caller handles the empty-list truth table before evaluating the
// LHS. Keep this defensive branch correct if the helper is reused.
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
return;
};
let Ok(lhs_arity) = i32::try_from(lhs_exprs.len()) else {
emit_in_probe_codegen_failure(b, "row-value IN arity exceeds VDBE register limits");
return;
};
// These must remain stable while every candidate is inspected: volatile
// LHS expressions are evaluated exactly once, not once per candidate.
let lhs_base = b.alloc_regs(lhs_arity);
for (field_index, lhs_expr) in lhs_exprs.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
emit_expr(b, lhs_expr, lhs_base + field_index as i32, ctx);
}
let matched_label = b.emit_label();
let null_label = b.emit_label();
let done_label = b.emit_label();
let saw_unknown = b.alloc_temp();
let candidate_unknown = b.alloc_temp();
let rhs_reg = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, saw_unknown, 0, P4::None, 0);
for rhs_exprs in rhs_rows {
let candidate_rejected = b.emit_label();
b.emit_op(Opcode::Integer, 0, candidate_unknown, 0, P4::None, 0);
for (field_index, ((lhs_expr, rhs_expr), donor_expr)) in
lhs_exprs.iter().zip(rhs_exprs).zip(donor_exprs).enumerate()
{
emit_expr(b, rhs_expr, rhs_reg, ctx);
let next_field = b.emit_label();
let field_unknown = b.emit_label();
let comparison_p4 =
comparison_collation_ctx(lhs_expr, donor_expr, ctx).map_or(P4::None, P4::Collation);
let comparison_affinity = comparison_affinity_p5(lhs_expr, donor_expr, ctx);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let lhs_reg = lhs_base + field_index as i32;
b.emit_jump_to_label(
Opcode::Eq,
rhs_reg,
lhs_reg,
next_field,
comparison_p4,
comparison_affinity,
);
b.emit_jump_to_label(Opcode::IsNull, lhs_reg, 0, field_unknown, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, rhs_reg, 0, field_unknown, P4::None, 0);
// Both fields are non-NULL and unequal. This candidate is FALSE,
// even if an earlier field had produced UNKNOWN.
b.emit_jump_to_label(Opcode::Goto, 0, 0, candidate_rejected, P4::None, 0);
b.resolve_label(field_unknown);
b.emit_op(Opcode::Integer, 1, candidate_unknown, 0, P4::None, 0);
b.resolve_label(next_field);
}
let mark_unknown = b.emit_label();
b.emit_jump_to_label(Opcode::If, candidate_unknown, 0, mark_unknown, P4::None, 0);
// Every field was definitely equal.
b.emit_jump_to_label(Opcode::Goto, 0, 0, matched_label, P4::None, 0);
b.resolve_label(mark_unknown);
b.emit_op(Opcode::Integer, 1, saw_unknown, 0, P4::None, 0);
b.resolve_label(candidate_rejected);
}
b.emit_jump_to_label(Opcode::If, saw_unknown, 0, null_label, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(matched_label);
b.emit_op(Opcode::Integer, i32::from(!not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(rhs_reg);
b.free_temp(candidate_unknown);
b.free_temp(saw_unknown);
}
/// Handles literals, bind parameters, binary/unary operators, CASE, CAST,
/// and (when `ctx` is provided) column references from a table scan cursor.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::too_many_lines,
clippy::many_single_char_names
)]
fn emit_expr(b: &mut ProgramBuilder, expr: &Expr, reg: i32, ctx: Option<&ScanCtx<'_>>) {
match expr {
Expr::Placeholder(pt, _) => {
let idx = match pt {
fsqlite_ast::PlaceholderType::Numbered(n) => *n as i32,
// Anonymous and named placeholders are assigned sequentially.
_ => b.next_anon_placeholder_idx() as i32,
};
b.emit_op(Opcode::Variable, idx, reg, 0, P4::None, 0);
}
Expr::Literal(lit, _) => match lit {
Literal::Integer(n) => {
if let Ok(as_i32) = i32::try_from(*n) {
b.emit_op(Opcode::Integer, as_i32, reg, 0, P4::None, 0);
} else {
b.emit_op(Opcode::Int64, 0, reg, 0, P4::Int64(*n), 0);
}
}
Literal::Float(f) => {
b.emit_op(Opcode::Real, 0, reg, 0, P4::Real(*f), 0);
}
Literal::String(s) => {
b.emit_op(Opcode::String8, 0, reg, 0, P4::Str(s.clone()), 0);
}
Literal::Blob(bytes) => {
b.emit_op(
Opcode::Blob,
bytes.len() as i32,
reg,
0,
P4::Blob(bytes.clone()),
0,
);
}
Literal::True => {
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
}
Literal::False => {
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
}
Literal::CurrentTimestamp | Literal::CurrentDate | Literal::CurrentTime => {
let ts = current_time_literal_text(lit)
.expect("current-time literals should resolve to a string");
b.emit_op(Opcode::String8, 0, reg, 0, P4::Str(ts), 0);
}
Literal::Null => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
},
Expr::BoundOuterValue { value, .. } => emit_sqlite_value(b, value, reg),
Expr::BinaryOp {
left, op, right, ..
} => {
emit_binary_op(b, left, *op, right, reg, ctx);
}
Expr::UnaryOp {
op, expr: operand, ..
} => {
emit_expr(b, operand, reg, ctx);
match op {
fsqlite_ast::UnaryOp::Negate => {
// Multiply by -1: Integer(-1) into temp, then Multiply.
let tmp = b.alloc_temp();
b.emit_op(Opcode::Integer, -1, tmp, 0, P4::None, 0);
b.emit_op(Opcode::Multiply, tmp, reg, reg, P4::None, 0);
b.free_temp(tmp);
}
fsqlite_ast::UnaryOp::Plus => { /* no-op */ }
fsqlite_ast::UnaryOp::BitNot => {
b.emit_op(Opcode::BitNot, reg, reg, 0, P4::None, 0);
}
fsqlite_ast::UnaryOp::Not => {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
}
Expr::Cast {
expr: inner,
type_name,
..
} => {
emit_expr(b, inner, reg, ctx);
let affinity = type_name_to_affinity(type_name);
b.emit_op(Opcode::Cast, reg, i32::from(affinity), 0, P4::None, 0);
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
emit_case_expr(b, operand.as_deref(), whens, else_expr.as_deref(), reg, ctx);
}
Expr::IsNull {
expr: inner, not, ..
} => {
// IS NULL → result 1 if null, 0 otherwise.
// IS NOT NULL → result 0 if null, 1 otherwise.
emit_expr(b, inner, reg, ctx);
let lbl_null = b.emit_label();
let lbl_done = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, reg, 0, lbl_null, P4::None, 0);
// Not null path.
let val_not_null = i32::from(*not); // IS NOT NULL: 1; IS NULL: 0
let val_null = i32::from(!*not); // IS NOT NULL: 0; IS NULL: 1
b.emit_op(Opcode::Integer, val_not_null, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, lbl_done, P4::None, 0);
b.resolve_label(lbl_null);
b.emit_op(Opcode::Integer, val_null, reg, 0, P4::None, 0);
b.resolve_label(lbl_done);
}
Expr::Like {
expr: operand,
pattern,
escape,
op: like_op,
not,
..
} => {
if use_builtin_like_glob_semantics()
&& matches!(like_op, fsqlite_ast::LikeOp::Like)
&& escape.is_none()
&& let Expr::Literal(Literal::String(pattern_text), _) = pattern.as_ref()
&& let Some((kind, literal)) = classify_sql_like_fast_path(pattern_text, None)
{
emit_expr(b, operand, reg, ctx);
b.emit_op(
Opcode::LikeConstFast,
reg,
reg,
kind.opcode_tag(),
P4::Str(literal.to_owned()),
u16::from(*not),
);
return;
}
let func_name = match like_op {
fsqlite_ast::LikeOp::Like => "LIKE",
fsqlite_ast::LikeOp::Glob => "GLOB",
fsqlite_ast::LikeOp::Match => "MATCH",
fsqlite_ast::LikeOp::Regexp => "REGEXP",
};
let nargs: u16 = if escape.is_some() { 3 } else { 2 };
let arg_base = b.alloc_regs(i32::from(nargs));
// like(pattern, string [, escape])
emit_expr(b, pattern, arg_base, ctx);
emit_expr(b, operand, arg_base + 1, ctx);
if let Some(esc) = escape {
emit_expr(b, esc, arg_base + 2, ctx);
}
let function_p4 =
if scalar_consumes_argument_collation_for_codegen(func_name, i32::from(nargs)) {
scalar_function_argument_collation_ctx(
[pattern.as_ref(), operand.as_ref()]
.into_iter()
.chain(escape.as_deref()),
ctx,
)
.map_or_else(
|| P4::FuncName(func_name.to_owned()),
|collation| P4::FuncNameCollated(func_name.to_owned(), collation),
)
} else {
P4::FuncName(func_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, nargs);
if *not {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
Expr::Between {
expr: operand,
low,
high,
not,
..
} => {
// BETWEEN low AND high → (operand >= low) AND (operand <= high)
// with three-valued NULL logic:
// NULL BETWEEN x AND y → NULL
// v BETWEEN NULL AND y → NULL when v <= y, FALSE when v > y
// v BETWEEN x AND NULL → NULL when v >= x, FALSE when v < x
let r_operand = b.alloc_temp();
let r_low = b.alloc_temp();
let r_high = b.alloc_temp();
emit_expr(b, operand, r_operand, ctx);
emit_expr(b, low, r_low, ctx);
emit_expr(b, high, r_high, ctx);
// BETWEEN is two comparisons. Each independently uses the
// operand's collation first, then that comparison's RHS.
let low_collation_p4 =
comparison_collation_ctx(operand, low, ctx).map_or(P4::None, P4::Collation);
let high_collation_p4 =
comparison_collation_ctx(operand, high, ctx).map_or(P4::None, P4::Collation);
let low_aff = comparison_affinity_p5(operand, low, ctx);
let high_aff = comparison_affinity_p5(operand, high, ctx);
let false_label = b.emit_label();
let null_label = b.emit_label();
let done_label = b.emit_label();
// If operand is NULL, short-circuit to NULL result.
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
// Jump to false if operand < low (NULL low → no jump, handled below).
b.emit_jump_to_label(
Opcode::Lt,
r_low,
r_operand,
false_label,
low_collation_p4,
low_aff,
);
// Jump to false if operand > high (NULL high → no jump, handled below).
b.emit_jump_to_label(
Opcode::Gt,
r_high,
r_operand,
false_label,
high_collation_p4,
high_aff,
);
// Passed both comparisons. If either bound was NULL the comparison
// silently fell through instead of confirming the range, so the
// correct three-valued result is NULL, not TRUE.
b.emit_jump_to_label(Opcode::IsNull, r_low, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_high, 0, null_label, P4::None, 0);
// Genuinely in range with no NULLs involved.
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(false_label);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_high);
b.free_temp(r_low);
b.free_temp(r_operand);
}
Expr::In {
expr: operand,
set,
not,
..
} => {
if let fsqlite_ast::InSet::List(values) = set {
if values.is_empty() {
// x IN () is always FALSE, x NOT IN () is always TRUE, even if x is NULL.
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
return;
}
if let Expr::RowValue(lhs_exprs, _) = operand.as_ref() {
emit_row_value_in_list(b, lhs_exprs, values, *not, reg, ctx);
return;
}
if values
.iter()
.any(|value| matches!(value, Expr::RowValue(..)))
{
emit_in_probe_codegen_failure(
b,
"scalar IN list contains a row-value candidate",
);
return;
}
if can_use_once_materialized_in_list(values, operand, ctx) {
emit_once_materialized_in_list(b, operand, values, *not, reg, ctx);
return;
}
// IN (v1, v2, ...) → chain of equality checks with
// three-valued NULL semantics (SQL standard):
// NULL IN (...) → NULL
// v IN (a, NULL, b) miss → NULL (NULL in list)
// v IN (a, b, c) miss → FALSE (no NULLs)
// v IN (...) hit → TRUE
let r_operand = b.alloc_temp();
emit_expr(b, operand, r_operand, ctx);
// A constant singleton IN-list is comparison-equivalent in
// SQLite, so its RHS may supply an explicit/declared collation.
// Longer or row-dependent lists use only the LHS collation.
let in_collation = if values.len() == 1 && singleton_in_rhs_is_constant(&values[0])
{
comparison_collation_ctx(operand, &values[0], ctx)
} else {
extract_collation(operand)
.map(str::to_owned)
.or_else(|| declared_collation_ctx(operand, ctx).map(str::to_owned))
};
let collation_p4 = in_collation.map_or(P4::None, P4::Collation);
// Apply the operand's comparison affinity to each list value, so
// `INTEGER_col IN ('1','5')` coerces the text literals to numbers
// exactly like a plain `=`/BETWEEN comparison (bd-cfmf6/bd-56aj2).
let in_aff = in_operand_affinity_p5(operand, ctx);
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
// If operand is NULL, short-circuit to NULL result.
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
// r_saw_null: set to 1 at runtime if any list element is NULL.
let r_saw_null = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
let r_val = b.alloc_temp();
for val_expr in values {
emit_expr(b, val_expr, r_val, ctx);
b.emit_jump_to_label(
Opcode::Eq,
r_val,
r_operand,
true_label,
collation_p4.clone(),
in_aff,
);
// Eq with NULL never jumps. If this value was NULL, flag it.
let next_val = b.emit_label();
let set_flag = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_val, 0, set_flag, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_val, P4::None, 0);
b.resolve_label(set_flag);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(next_val);
}
b.free_temp(r_val);
// No match. If any list element was NULL → result is NULL.
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
b.free_temp(r_saw_null);
// Definite no-match with no NULLs → FALSE (or TRUE for NOT IN).
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_operand);
} else {
if matches!(operand.as_ref(), Expr::RowValue(..)) {
emit_in_probe_codegen_failure(
b,
"multi-column subquery/table probes require prior list materialization",
);
return;
}
emit_in_probe_expr(b, operand, set, *not, reg, ctx);
}
}
Expr::FunctionCall { name, args, .. } if !is_aggregate_function_call(name, args) => {
// Scalar function call: emit args, then PureFunc.
let canon = name.to_ascii_uppercase();
match args {
fsqlite_ast::FunctionArgs::Star => {
// func(*) for non-aggregate → 0 args.
b.emit_op(Opcode::PureFunc, 0, 0, reg, P4::FuncName(canon), 0);
}
fsqlite_ast::FunctionArgs::List(arg_list) => {
if let Some(scan_ctx) = ctx
&& (try_emit_column_substr_prefix(b, name, arg_list, reg, scan_ctx)
|| try_emit_column_octet_length(b, name, arg_list, reg, scan_ctx))
{
return;
}
let Ok(nargs) = u16::try_from(arg_list.len()) else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
};
let arg_base = b.alloc_regs(i32::from(nargs));
for (i, arg_expr) in arg_list.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
emit_expr(b, arg_expr, arg_base + i as i32, ctx);
}
b.emit_op(
Opcode::PureFunc,
0,
arg_base,
reg,
scalar_function_p4(canon, arg_list, ctx),
nargs,
);
}
}
}
Expr::Column(col_ref, _) => {
let Some(sc) = ctx else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
};
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, sc.table, sc.table_alias)
{
// Check secondary table contexts (UPDATE ... FROM, possibly
// multiple FROM sources joined together).
if let Some(sec) = sc
.secondaries
.iter()
.find(|sec| matches_table_or_alias(qualifier, sec.table, sec.table_alias))
{
emit_secondary_column(b, &col_ref.column, sec, reg);
return;
}
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
}
// Register-based resolution for generated column expressions
// during INSERT: copy from the register holding that column's value.
if let Some(reg_base) = sc.register_base {
if let Some(col_idx) = sc.table.column_index(&col_ref.column) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
if let Some(gen_expr) =
virtual_generated_column_expr(&sc.table.columns[col_idx])
{
// bd-r3303: a VIRTUAL generated column has only a NULL
// placeholder register (it is never materialized), so when
// it is referenced in a write-time context — building an
// index key over it, or a STORED column / CHECK constraint
// that reads it — recompute it from the sibling column
// registers and coerce to its declared affinity.
b.with_schema_evaluation_context(
SchemaEvaluationContext::GeneratedColumn,
|b| emit_expr(b, &gen_expr, reg, Some(sc)),
);
emit_single_column_affinity(b, reg, sc.table.columns[col_idx].affinity);
} else {
b.emit_op(Opcode::Copy, reg_base + col_idx as i32, reg, 0, P4::None, 0);
}
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
} else if let Some(col_idx) = sc.table.column_index(&col_ref.column) {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
if sc.table.columns[col_idx].is_ipk {
b.emit_op(Opcode::Rowid, sc.cursor, reg, 0, P4::None, 0);
} else if let Some(gen_expr) =
virtual_generated_column_expr(&sc.table.columns[col_idx])
{
// bd-r3303: a VIRTUAL generated column is not materialized in
// the stored record, so compute it on read by emitting its
// generating expression against the current cursor row (the
// referenced base columns resolve through this same path),
// then coerce to the column's declared affinity — matching
// what record packing applies to STORED columns at write.
b.with_schema_evaluation_context(
SchemaEvaluationContext::GeneratedColumn,
|b| emit_expr(b, &gen_expr, reg, Some(sc)),
);
emit_single_column_affinity(b, reg, sc.table.columns[col_idx].affinity);
} else {
b.emit_op(Opcode::Column, sc.cursor, col_idx as i32, reg, P4::None, 0);
}
} else if sc.table.resolves_to_hidden_rowid(&col_ref.column) {
b.emit_op(Opcode::Rowid, sc.cursor, reg, 0, P4::None, 0);
} else if let Some(sec) = sc
.secondaries
.iter()
.find(|sec| table_has_column_or_rowid(sec.table, &col_ref.column))
{
// Unqualified column not found in primary — resolve against the
// first secondary FROM source that has it.
emit_secondary_column(b, &col_ref.column, sec, reg);
} else {
// Unknown column — emit Null.
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
}
Expr::Collate { expr: inner, .. } => {
// Evaluate the inner expression; collation affects comparisons
// rather than value computation, so a pass-through is correct.
emit_expr(b, inner, reg, ctx);
}
Expr::Exists { subquery, not, .. } => {
if let Some(scan_ctx) = ctx
&& let Some(schema) = scan_ctx.schema
{
emit_exists_subquery(b, subquery, *not, reg, scan_ctx, schema);
return;
}
// No schema context — emit 0 (false) for EXISTS, 1 for NOT EXISTS.
let val = i32::from(*not);
b.emit_op(Opcode::Integer, val, reg, 0, P4::None, 0);
}
Expr::Subquery(subquery, _) => {
if let Some(scan_ctx) = ctx
&& let Some(schema) = scan_ctx.schema
{
emit_scalar_subquery(b, subquery, reg, scan_ctx, schema);
return;
}
// No schema context — emit NULL.
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
Expr::JsonAccess {
expr: inner,
path,
arrow,
..
} => {
let arg_base = b.alloc_regs(2);
emit_expr(b, inner, arg_base, ctx);
emit_expr(b, path, arg_base + 1, ctx);
let function_name = json_access_func_name(*arrow);
let function_p4 = if scalar_consumes_argument_collation_for_codegen(function_name, 2) {
scalar_function_argument_collation_ctx([inner.as_ref(), path.as_ref()], ctx)
.map_or_else(
|| P4::FuncName(function_name.to_owned()),
|collation| P4::FuncNameCollated(function_name.to_owned(), collation),
)
} else {
P4::FuncName(function_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, 2);
}
_ => {
// Column refs without scan context and other unhandled expressions: Null.
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
}
}
fn current_time_literal_text(literal: &Literal) -> Option<String> {
use fsqlite_types::sync_primitives::SystemTime;
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = secs / 86400;
let day_secs = secs % 86400;
let h = day_secs / 3600;
let m = (day_secs % 3600) / 60;
let s = day_secs % 60;
let (y, mo, d) = epoch_days_to_ymd(days);
Some(match *literal {
Literal::CurrentTimestamp => format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02}"),
Literal::CurrentDate => format!("{y:04}-{mo:02}-{d:02}"),
Literal::CurrentTime => format!("{h:02}:{m:02}:{s:02}"),
_ => return None,
})
}
/// Emit bytecode for an EXISTS or NOT EXISTS subquery expression.
///
/// Pattern: open cursor on subquery table, scan with WHERE filter, set reg to
/// 1 (found) or 0 (not found). For NOT EXISTS, the result is inverted.
fn flatten_and_terms<'a>(expr: &'a Expr, terms: &mut Vec<&'a Expr>) {
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} = expr
{
flatten_and_terms(left, terms);
flatten_and_terms(right, terms);
} else {
terms.push(expr);
}
}
fn expr_references_scan(expr: &Expr, table: &TableSchema, table_alias: Option<&str>) -> bool {
match expr {
Expr::Column(_, _) => resolve_column_ref(expr, table, table_alias).is_some(),
Expr::BinaryOp { left, right, .. } => {
expr_references_scan(left, table, table_alias)
|| expr_references_scan(right, table, table_alias)
}
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => expr_references_scan(expr, table, table_alias),
Expr::Between {
expr, low, high, ..
} => {
expr_references_scan(expr, table, table_alias)
|| expr_references_scan(low, table, table_alias)
|| expr_references_scan(high, table, table_alias)
}
Expr::In { expr, set, .. } => {
expr_references_scan(expr, table, table_alias)
|| match set {
fsqlite_ast::InSet::List(values) => values
.iter()
.any(|value| expr_references_scan(value, table, table_alias)),
fsqlite_ast::InSet::Table(_) | fsqlite_ast::InSet::Subquery(_) => true,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
expr_references_scan(expr, table, table_alias)
|| expr_references_scan(pattern, table, table_alias)
|| escape
.as_deref()
.is_some_and(|esc| expr_references_scan(esc, table, table_alias))
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_some_and(|inner| expr_references_scan(inner, table, table_alias))
|| whens.iter().any(|(when_expr, then_expr)| {
expr_references_scan(when_expr, table, table_alias)
|| expr_references_scan(then_expr, table, table_alias)
})
|| else_expr
.as_deref()
.is_some_and(|inner| expr_references_scan(inner, table, table_alias))
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
over.is_some()
|| match args {
FunctionArgs::Star => false,
FunctionArgs::List(values) => values
.iter()
.any(|value| expr_references_scan(value, table, table_alias)),
}
|| order_by
.iter()
.any(|term| expr_references_scan(&term.expr, table, table_alias))
|| filter
.as_deref()
.is_some_and(|inner| expr_references_scan(inner, table, table_alias))
}
Expr::JsonAccess { expr, path, .. } => {
expr_references_scan(expr, table, table_alias)
|| expr_references_scan(path, table, table_alias)
}
Expr::Exists { .. } | Expr::Subquery(_, _) => true,
_ => false,
}
}
const ONCE_MATERIALIZED_IN_LIST_THRESHOLD: usize = 8;
fn in_list_value_supports_once_materialization(expr: &Expr) -> bool {
match expr {
Expr::Literal(_, _) | Expr::Placeholder(_, _) => true,
Expr::UnaryOp { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => in_list_value_supports_once_materialization(inner),
_ => false,
}
}
/// Whether SQLite may treat a one-element IN-list as an ordinary binary
/// comparison for collation precedence. This is narrower than "evaluates to
/// one value": table columns, scalar subqueries, and non-deterministic calls
/// remain genuine IN-list terms and therefore cannot donate an RHS collation.
/// Registry metadata admits both deterministic and SQLite slow-changing scalar
/// calls, provided every argument is recursively query-constant.
fn singleton_in_rhs_is_constant(expr: &Expr) -> bool {
match expr {
Expr::Literal(_, _) | Expr::Placeholder(_, _) => true,
Expr::BinaryOp { left, right, .. } => {
singleton_in_rhs_is_constant(left) && singleton_in_rhs_is_constant(right)
}
Expr::JsonAccess {
expr: left,
path: right,
arrow,
..
} => {
let name = json_access_func_name(*arrow);
scalar_is_query_constant_for_codegen(name, 2)
&& singleton_in_rhs_is_constant(left)
&& singleton_in_rhs_is_constant(right)
}
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => singleton_in_rhs_is_constant(expr),
Expr::Between {
expr, low, high, ..
} => {
singleton_in_rhs_is_constant(expr)
&& singleton_in_rhs_is_constant(low)
&& singleton_in_rhs_is_constant(high)
}
Expr::In { expr, set, .. } => {
singleton_in_rhs_is_constant(expr)
&& matches!(set, InSet::List(values) if values.iter().all(singleton_in_rhs_is_constant))
}
Expr::Like {
expr,
pattern,
escape,
op,
..
} => {
let (name, arity) = match op {
fsqlite_ast::LikeOp::Like => ("like", if escape.is_some() { 3 } else { 2 }),
fsqlite_ast::LikeOp::Glob => ("glob", 2),
fsqlite_ast::LikeOp::Match => ("match", 2),
fsqlite_ast::LikeOp::Regexp => ("regexp", 2),
};
scalar_is_query_constant_for_codegen(name, arity)
&& singleton_in_rhs_is_constant(expr)
&& singleton_in_rhs_is_constant(pattern)
&& escape.as_deref().is_none_or(singleton_in_rhs_is_constant)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_deref().is_none_or(singleton_in_rhs_is_constant)
&& whens.iter().all(|(condition, value)| {
singleton_in_rhs_is_constant(condition) && singleton_in_rhs_is_constant(value)
})
&& else_expr
.as_deref()
.is_none_or(singleton_in_rhs_is_constant)
}
Expr::FunctionCall {
name,
args,
distinct,
order_by,
filter,
over,
..
} => {
if *distinct || !order_by.is_empty() || filter.is_some() || over.is_some() {
return false;
}
let (arity, arguments_are_constant) = match args {
FunctionArgs::Star => return false,
FunctionArgs::List(values) => {
let Ok(arity) = i32::try_from(values.len()) else {
return false;
};
(arity, values.iter().all(singleton_in_rhs_is_constant))
}
};
arguments_are_constant && scalar_is_query_constant_for_codegen(name, arity)
}
Expr::RowValue(values, _) => {
!values.is_empty() && values.iter().all(singleton_in_rhs_is_constant)
}
Expr::BoundOuterValue { .. }
| Expr::Column(_, _)
| Expr::Subquery(_, _)
| Expr::Exists { .. }
| Expr::Raise { .. } => false,
}
}
fn can_use_once_materialized_in_list(
values: &[Expr],
_operand: &Expr,
_ctx: Option<&ScanCtx<'_>>,
) -> bool {
if values.len() < ONCE_MATERIALIZED_IN_LIST_THRESHOLD {
return false;
}
if !values
.iter()
.all(in_list_value_supports_once_materialization)
{
return false;
}
true
}
fn probe_expr_references_outer_scan(
expr: &Expr,
probe_source: &InProbeSource<'_>,
scan_ctx: &ScanCtx<'_>,
) -> bool {
match expr {
Expr::Column(_, _) => {
if resolve_column_ref(expr, probe_source.table, probe_source.table_alias).is_some() {
return false;
}
resolve_column_ref(expr, scan_ctx.table, scan_ctx.table_alias).is_some()
|| scan_ctx.secondaries.iter().any(|secondary| {
resolve_column_ref(expr, secondary.table, secondary.table_alias).is_some()
})
}
Expr::BinaryOp { left, right, .. } => {
probe_expr_references_outer_scan(left, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(right, probe_source, scan_ctx)
}
Expr::UnaryOp { expr: inner, .. }
| Expr::IsNull { expr: inner, .. }
| Expr::Cast { expr: inner, .. }
| Expr::Collate { expr: inner, .. } => {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
}
Expr::Between {
expr: inner,
low,
high,
..
} => {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(low, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(high, probe_source, scan_ctx)
}
Expr::In {
expr: inner, set, ..
} => {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
|| match set {
fsqlite_ast::InSet::List(items) => items
.iter()
.any(|item| probe_expr_references_outer_scan(item, probe_source, scan_ctx)),
// Recursively resolving a nested SELECT would require a
// separate scope stack. Fail closed instead of overlooking
// a correlation hidden inside that SELECT.
fsqlite_ast::InSet::Subquery(_) => true,
// A table-name shorthand contains no expression that can
// reference the outer scan.
fsqlite_ast::InSet::Table(_) => false,
}
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
matches!(
args,
FunctionArgs::List(items)
if items
.iter()
.any(|item| probe_expr_references_outer_scan(item, probe_source, scan_ctx))
) || order_by
.iter()
.any(|term| probe_expr_references_outer_scan(&term.expr, probe_source, scan_ctx))
|| filter.as_deref().is_some_and(|expr| {
probe_expr_references_outer_scan(expr, probe_source, scan_ctx)
})
|| over.as_ref().is_some_and(|spec| {
probe_window_spec_references_outer_scan(spec, probe_source, scan_ctx)
})
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand.as_ref().is_some_and(|inner| {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
}) || whens.iter().any(|(cond, then_expr)| {
probe_expr_references_outer_scan(cond, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(then_expr, probe_source, scan_ctx)
}) || else_expr.as_ref().is_some_and(|inner| {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
})
}
Expr::Like {
expr: inner,
pattern,
escape,
..
} => {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(pattern, probe_source, scan_ctx)
|| escape.as_ref().is_some_and(|inner| {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
})
}
Expr::JsonAccess {
expr: inner, path, ..
} => {
probe_expr_references_outer_scan(inner, probe_source, scan_ctx)
|| probe_expr_references_outer_scan(path, probe_source, scan_ctx)
}
Expr::RowValue(items, _) => items
.iter()
.any(|item| probe_expr_references_outer_scan(item, probe_source, scan_ctx)),
// As above, nested SELECT scoping is deliberately unsupported here.
Expr::Exists { .. } | Expr::Subquery(_, _) => true,
Expr::Literal(_, _)
| Expr::BoundOuterValue { .. }
| Expr::Placeholder(_, _)
| Expr::Raise { .. } => false,
}
}
fn probe_window_spec_references_outer_scan(
spec: &fsqlite_ast::WindowSpec,
probe_source: &InProbeSource<'_>,
scan_ctx: &ScanCtx<'_>,
) -> bool {
spec.partition_by
.iter()
.any(|expr| probe_expr_references_outer_scan(expr, probe_source, scan_ctx))
|| spec
.order_by
.iter()
.any(|term| probe_expr_references_outer_scan(&term.expr, probe_source, scan_ctx))
|| spec.frame.as_ref().is_some_and(|frame| {
probe_frame_bound_references_outer_scan(&frame.start, probe_source, scan_ctx)
|| frame.end.as_ref().is_some_and(|bound| {
probe_frame_bound_references_outer_scan(bound, probe_source, scan_ctx)
})
})
}
fn probe_frame_bound_references_outer_scan(
bound: &fsqlite_ast::FrameBound,
probe_source: &InProbeSource<'_>,
scan_ctx: &ScanCtx<'_>,
) -> bool {
match bound {
fsqlite_ast::FrameBound::Preceding(expr) | fsqlite_ast::FrameBound::Following(expr) => {
probe_expr_references_outer_scan(expr, probe_source, scan_ctx)
}
fsqlite_ast::FrameBound::UnboundedPreceding
| fsqlite_ast::FrameBound::CurrentRow
| fsqlite_ast::FrameBound::UnboundedFollowing => false,
}
}
fn in_probe_source_references_outer_scan(
probe_source: &InProbeSource<'_>,
scan_ctx: &ScanCtx<'_>,
) -> bool {
probe_source.where_clause.is_some_and(|where_expr| {
probe_expr_references_outer_scan(where_expr, probe_source, scan_ctx)
}) || matches!(
probe_source.value,
InProbeValue::Expr(expr)
if probe_expr_references_outer_scan(expr, probe_source, scan_ctx)
)
}
fn can_use_once_materialized_in_probe_source(
probe_source: &InProbeSource<'_>,
_operand: &Expr,
scan_ctx: &ScanCtx<'_>,
) -> bool {
if in_probe_source_references_outer_scan(probe_source, scan_ctx) {
return false;
}
true
}
fn expr_contains_nested_subquery(expr: &Expr) -> bool {
match expr {
Expr::Exists { .. } | Expr::Subquery(_, _) => true,
Expr::BinaryOp { left, right, .. } => {
expr_contains_nested_subquery(left) || expr_contains_nested_subquery(right)
}
Expr::UnaryOp { expr, .. }
| Expr::Cast { expr, .. }
| Expr::Collate { expr, .. }
| Expr::IsNull { expr, .. } => expr_contains_nested_subquery(expr),
Expr::Between {
expr, low, high, ..
} => {
expr_contains_nested_subquery(expr)
|| expr_contains_nested_subquery(low)
|| expr_contains_nested_subquery(high)
}
Expr::In { expr, set, .. } => {
expr_contains_nested_subquery(expr)
|| match set {
InSet::List(values) => values.iter().any(expr_contains_nested_subquery),
InSet::Table(_) | InSet::Subquery(_) => true,
}
}
Expr::Like {
expr,
pattern,
escape,
..
} => {
expr_contains_nested_subquery(expr)
|| expr_contains_nested_subquery(pattern)
|| escape.as_deref().is_some_and(expr_contains_nested_subquery)
}
Expr::Case {
operand,
whens,
else_expr,
..
} => {
operand
.as_deref()
.is_some_and(expr_contains_nested_subquery)
|| whens.iter().any(|(when_expr, then_expr)| {
expr_contains_nested_subquery(when_expr)
|| expr_contains_nested_subquery(then_expr)
})
|| else_expr
.as_deref()
.is_some_and(expr_contains_nested_subquery)
}
Expr::FunctionCall {
args,
order_by,
filter,
over,
..
} => {
over.is_some()
|| match args {
FunctionArgs::Star => false,
FunctionArgs::List(values) => values.iter().any(expr_contains_nested_subquery),
}
|| order_by
.iter()
.any(|term| expr_contains_nested_subquery(&term.expr))
|| filter.as_deref().is_some_and(expr_contains_nested_subquery)
}
Expr::JsonAccess {
expr: inner, path, ..
} => expr_contains_nested_subquery(inner) || expr_contains_nested_subquery(path),
Expr::RowValue(values, _) => values.iter().any(expr_contains_nested_subquery),
_ => false,
}
}
fn can_use_once_materialized_exists_subquery(
where_clause: Option<&Expr>,
table: &TableSchema,
table_alias: Option<&str>,
outer_ctx: &ScanCtx<'_>,
) -> bool {
let Some(where_expr) = where_clause else {
return true;
};
if expr_contains_nested_subquery(where_expr) {
return false;
}
let probe_source = InProbeSource {
table,
table_alias,
where_clause: Some(where_expr),
value: InProbeValue::FirstColumn,
};
!probe_expr_references_outer_scan(where_expr, &probe_source, outer_ctx)
}
fn emit_once_materialized_in_list(
b: &mut ProgramBuilder,
operand: &Expr,
values: &[Expr],
not: bool,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
let r_operand = b.alloc_temp();
emit_expr(b, operand, r_operand, ctx);
// SQLite applies the operand's affinity to both the materialized IN-set
// values and the probe key (bd-cfmf6/bd-56aj2): an INTEGER operand coerces
// text list literals to numbers before they enter / are probed against the
// ephemeral autoindex. `None` means BLOB/NONE affinity (no coercion).
let in_aff_str: Option<String> = u8::try_from(in_operand_affinity_p5(operand, ctx))
.ok()
.filter(|&code| code != 0)
.map(|code| (code as char).to_string());
let null_label = b.emit_label();
let found_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let autoindex_cursor = 4096 + b.current_addr() as i32;
let r_saw_null = b.alloc_reg();
let build_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, build_done, P4::None, 0);
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
let autoindex_collation = effective_collation_ctx(operand, ctx)
.map_or(P4::None, |coll| P4::Collation(coll.to_owned()));
b.emit_op(
Opcode::OpenAutoindex,
autoindex_cursor,
1,
0,
autoindex_collation,
0,
);
let r_value = b.alloc_temp();
let r_key = b.alloc_temp();
for value_expr in values {
emit_expr(b, value_expr, r_value, ctx);
if let Some(ref aff) = in_aff_str {
b.emit_op(
Opcode::Affinity,
r_value,
1,
0,
P4::Affinity(aff.clone()),
0,
);
}
let next_value = b.emit_label();
let saw_null_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_value, 0, saw_null_label, P4::None, 0);
b.emit_op(Opcode::MakeRecord, r_value, 1, r_key, P4::None, 0);
b.emit_op(Opcode::IdxInsert, autoindex_cursor, r_key, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_value, P4::None, 0);
b.resolve_label(saw_null_label);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(next_value);
}
b.free_temp(r_key);
b.free_temp(r_value);
b.resolve_label(build_done);
let r_probe_key = b.alloc_temp();
if let Some(ref aff) = in_aff_str {
b.emit_op(
Opcode::Affinity,
r_operand,
1,
0,
P4::Affinity(aff.clone()),
0,
);
}
b.emit_op(Opcode::MakeRecord, r_operand, 1, r_probe_key, P4::None, 0);
b.emit_jump_to_label(
Opcode::Found,
autoindex_cursor,
r_probe_key,
found_label,
P4::None,
0,
);
b.free_temp(r_probe_key);
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(found_label);
b.emit_op(Opcode::Integer, i32::from(!not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_operand);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_once_materialized_in_probe_source(
b: &mut ProgramBuilder,
operand: &Expr,
probe_source: &InProbeSource<'_>,
not: bool,
reg: i32,
scan_ctx: &ScanCtx<'_>,
schema: &[TableSchema],
) {
let r_operand = b.alloc_temp();
let null_label = b.emit_label();
let found_label = b.emit_label();
let done_label = b.emit_label();
let source_cursor = 8192 + b.current_addr() as i32;
let autoindex_cursor = 12288 + b.current_addr() as i32;
let r_saw_null = b.alloc_reg();
let r_rhs_nonempty = b.alloc_reg();
let probe_scan = ScanCtx {
cursor: source_cursor,
table: probe_source.table,
table_alias: probe_source.table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
let build_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, build_done, P4::None, 0);
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
b.emit_op(Opcode::Integer, 0, r_rhs_nonempty, 0, P4::None, 0);
let autoindex_collation =
in_probe_comparison_collation(operand, scan_ctx, probe_source, &probe_scan)
.map_or(P4::None, P4::Collation);
b.emit_op(
Opcode::OpenAutoindex,
autoindex_cursor,
1,
0,
autoindex_collation,
0,
);
b.emit_op(
Opcode::OpenRead,
source_cursor,
probe_source.table.root_page,
0,
P4::Table(probe_source.table.name.clone()),
0,
);
let build_scan_done = b.emit_label();
let build_scan_start = b.current_addr();
b.emit_jump_to_label(
Opcode::Rewind,
source_cursor,
0,
build_scan_done,
P4::None,
0,
);
let skip_label = probe_source.where_clause.map(|_| b.emit_label());
if let (Some(where_expr), Some(skip)) = (probe_source.where_clause, skip_label) {
emit_where_filter(
b,
where_expr,
source_cursor,
probe_source.table,
probe_source.table_alias,
schema,
skip,
);
}
// Apply the comparison affinity between the outer operand and the subquery
// probe column to both the materialized values and the probe key, mirroring
// the per-row IN-subquery path (bd-56aj2 IN-subquery).
let probe_aff = in_probe_value_affinity(probe_source, &probe_scan);
let in_aff_str: Option<String> = u8::try_from(combine_comparison_affinity(
expr_affinity(operand, Some(scan_ctx)),
probe_aff,
))
.ok()
.filter(|&code| code != 0)
.map(|code| (code as char).to_string());
let r_value = b.alloc_temp();
let r_key = b.alloc_temp();
b.emit_op(Opcode::Integer, 1, r_rhs_nonempty, 0, P4::None, 0);
emit_in_probe_value(b, source_cursor, probe_source, r_value, &probe_scan);
if let Some(ref aff) = in_aff_str {
b.emit_op(
Opcode::Affinity,
r_value,
1,
0,
P4::Affinity(aff.clone()),
0,
);
}
let next_value = b.emit_label();
let saw_null_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_value, 0, saw_null_label, P4::None, 0);
b.emit_op(Opcode::MakeRecord, r_value, 1, r_key, P4::None, 0);
b.emit_op(Opcode::IdxInsert, autoindex_cursor, r_key, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_value, P4::None, 0);
b.resolve_label(saw_null_label);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(next_value);
if let Some(skip) = skip_label {
b.resolve_label(skip);
}
let loop_body = (build_scan_start + 1) as i32;
b.emit_op(Opcode::Next, source_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(build_scan_done);
b.emit_op(Opcode::Close, source_cursor, 0, 0, P4::None, 0);
b.free_temp(r_key);
b.free_temp(r_value);
b.resolve_label(build_done);
emit_expr(b, operand, r_operand, Some(scan_ctx));
let r_probe_key = b.alloc_temp();
if let Some(ref aff) = in_aff_str {
b.emit_op(
Opcode::Affinity,
r_operand,
1,
0,
P4::Affinity(aff.clone()),
0,
);
}
b.emit_op(Opcode::MakeRecord, r_operand, 1, r_probe_key, P4::None, 0);
b.emit_jump_to_label(
Opcode::Found,
autoindex_cursor,
r_probe_key,
found_label,
P4::None,
0,
);
b.free_temp(r_probe_key);
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
let definite_miss = b.emit_label();
let check_null_operand = b.emit_label();
b.emit_jump_to_label(
Opcode::IsNull,
r_operand,
0,
check_null_operand,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, definite_miss, P4::None, 0);
b.resolve_label(check_null_operand);
b.emit_jump_to_label(Opcode::If, r_rhs_nonempty, 0, null_label, P4::None, 0);
b.resolve_label(definite_miss);
b.emit_op(Opcode::Integer, i32::from(not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(found_label);
b.emit_op(Opcode::Integer, i32::from(!not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_operand);
}
fn extract_exists_rowid_probe<'a>(
where_expr: &'a Expr,
table: &'a TableSchema,
table_alias: Option<&'a str>,
) -> Option<(&'a Expr, Vec<&'a Expr>)> {
let mut terms = Vec::new();
flatten_and_terms(where_expr, &mut terms);
let mut probe_expr = None;
let mut residual_terms = Vec::new();
for term in terms {
if let Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Eq,
right,
..
} = term
{
let left_rowid = matches!(
resolve_column_ref(left, table, table_alias),
Some(SortKeySource::Rowid)
);
let right_rowid = matches!(
resolve_column_ref(right, table, table_alias),
Some(SortKeySource::Rowid)
);
if left_rowid
&& !expr_references_scan(right, table, table_alias)
&& !expr_contains_bound_outer_value(right)
&& !expr_contains_nested_subquery(right)
&& probe_expr.is_none()
{
probe_expr = Some(right.as_ref());
continue;
}
if right_rowid
&& !expr_references_scan(left, table, table_alias)
&& !expr_contains_bound_outer_value(left)
&& !expr_contains_nested_subquery(left)
&& probe_expr.is_none()
{
probe_expr = Some(left.as_ref());
continue;
}
}
residual_terms.push(term);
}
probe_expr.map(|probe| (probe, residual_terms))
}
#[allow(clippy::too_many_arguments)]
fn emit_once_materialized_exists_subquery(
b: &mut ProgramBuilder,
table: &TableSchema,
table_alias: Option<&str>,
where_clause: Option<&Expr>,
not: bool,
reg: i32,
outer_ctx: &ScanCtx<'_>,
schema: &[TableSchema],
) {
let sub_cursor = outer_ctx.cursor + 128;
let cached_reg = b.alloc_reg();
let build_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, build_done, P4::None, 0);
let default_val = i32::from(not);
b.emit_op(Opcode::Integer, default_val, cached_reg, 0, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::OpenRead,
sub_cursor,
table.root_page as i32,
0,
P4::Int(table.columns.len() as i32),
0,
);
let sub_ctx = ScanCtx {
cursor: sub_cursor,
table,
table_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
let scan_done = b.emit_label();
let rewind_addr = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, sub_cursor, 0, scan_done, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (rewind_addr + 1) as i32;
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
let next_label = b.emit_label();
emit_expr_with_fallback(b, where_expr, r_cond, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, next_label, P4::None, 0);
b.free_temp(r_cond);
b.emit_op(Opcode::Integer, i32::from(!not), cached_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, scan_done, P4::None, 0);
b.resolve_label(next_label);
} else {
b.emit_op(Opcode::Integer, i32::from(!not), cached_reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, scan_done, P4::None, 0);
}
b.emit_op(Opcode::Next, sub_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(scan_done);
b.emit_op(Opcode::Close, sub_cursor, 0, 0, P4::None, 0);
b.resolve_label(build_done);
b.emit_op(Opcode::Copy, cached_reg, reg, 0, P4::None, 0);
}
#[allow(clippy::too_many_lines)]
fn emit_exists_subquery(
b: &mut ProgramBuilder,
subquery: &SelectStatement,
not: bool,
reg: i32,
outer_ctx: &ScanCtx<'_>,
schema: &[TableSchema],
) {
// Extract the subquery's FROM table and WHERE clause.
let (from, where_clause) = match &subquery.body.select {
SelectCore::Select {
from, where_clause, ..
} => (from, where_clause),
_ => {
let val = i32::from(not);
b.emit_op(Opcode::Integer, val, reg, 0, P4::None, 0);
return;
}
};
let from_clause = match from {
Some(f) => f,
None => {
// EXISTS on a no-FROM query like `EXISTS (SELECT 1)` — always true.
let val = i32::from(!not);
b.emit_op(Opcode::Integer, val, reg, 0, P4::None, 0);
return;
}
};
let (table_name, sub_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.as_deref()),
_ => {
let val = i32::from(not);
b.emit_op(Opcode::Integer, val, reg, 0, P4::None, 0);
return;
}
};
let table = match find_table(schema, table_name) {
Ok(t) => t,
Err(_) => {
let val = i32::from(not);
b.emit_op(Opcode::Integer, val, reg, 0, P4::None, 0);
return;
}
};
// Use cursor offset far from the main scan cursors.
let sub_cursor = outer_ctx.cursor + 128;
let done_label = b.emit_label();
// Default result: 0 (not found) for EXISTS, 1 for NOT EXISTS.
let default_val = i32::from(not);
b.emit_op(Opcode::Integer, default_val, reg, 0, P4::None, 0);
// Open a read cursor on the subquery table.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::OpenRead,
sub_cursor,
table.root_page as i32,
0,
P4::Int(table.columns.len() as i32),
0,
);
let sub_ctx = ScanCtx {
cursor: sub_cursor,
table,
table_alias: sub_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
if subquery.with.is_none()
&& subquery.body.compounds.is_empty()
&& subquery.order_by.is_empty()
&& subquery.limit.is_none()
&& from_clause.joins.is_empty()
&& matches!(
&subquery.body.select,
SelectCore::Select {
group_by,
having,
windows,
..
} if group_by.is_empty() && having.is_none() && windows.is_empty()
)
&& can_use_once_materialized_exists_subquery(
where_clause.as_deref(),
table,
sub_alias,
outer_ctx,
)
{
emit_once_materialized_exists_subquery(
b,
table,
sub_alias,
where_clause.as_deref(),
not,
reg,
outer_ctx,
schema,
);
return;
}
if let Some(where_expr) = where_clause
&& let Some((probe_expr, residual_terms)) =
extract_exists_rowid_probe(where_expr, table, sub_alias)
{
let probe_reg = b.alloc_temp();
emit_expr_with_fallback(b, probe_expr, probe_reg, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, done_label, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
sub_cursor,
probe_reg,
done_label,
P4::None,
0,
);
b.free_temp(probe_reg);
for residual_term in residual_terms {
let residual_reg = b.alloc_temp();
emit_expr_with_fallback(b, residual_term, residual_reg, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, residual_reg, 1, done_label, P4::None, 0);
b.free_temp(residual_reg);
}
let found_val = i32::from(!not);
b.emit_op(Opcode::Integer, found_val, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, sub_cursor, 0, 0, P4::None, 0);
return;
}
let rewind_addr = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, sub_cursor, 0, done_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (rewind_addr + 1) as i32;
// Apply WHERE filter if present.
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
emit_expr_with_fallback(b, where_expr, r_cond, &sub_ctx, Some(outer_ctx));
let next_label = b.emit_label();
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, next_label, P4::None, 0);
b.free_temp(r_cond);
// Row matches WHERE — found.
let found_val = i32::from(!not);
b.emit_op(Opcode::Integer, found_val, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next_label);
} else {
// No WHERE — if any row exists, result is found.
let found_val = i32::from(!not);
b.emit_op(Opcode::Integer, found_val, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
}
// Next row.
b.emit_op(Opcode::Next, sub_cursor, loop_body, 0, P4::None, 0);
// Fall through: no row matched.
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, sub_cursor, 0, 0, P4::None, 0);
}
/// Emit the first scalar result expression against an already-positioned
/// single-table subquery cursor.
fn emit_scalar_subquery_result_value(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
reg: i32,
sub_ctx: &ScanCtx<'_>,
outer_ctx: &ScanCtx<'_>,
) {
match columns.first() {
Some(ResultColumn::Expr { expr, .. }) => {
emit_expr_with_fallback(b, expr, reg, sub_ctx, Some(outer_ctx));
}
Some(ResultColumn::Star | ResultColumn::TableStar(_))
if sub_ctx.table.columns.len() == 1 =>
{
emit_table_column_read(
b,
sub_ctx.cursor,
sub_ctx.table,
sub_ctx.table_alias,
sub_ctx.schema,
0,
reg,
);
}
_ => {}
}
}
/// Emit bytecode for a scalar subquery expression `(SELECT expr FROM ...)`.
///
/// Evaluates the subquery and places the first result value into `reg`.
/// If the subquery returns no rows, `reg` is set to NULL.
#[allow(clippy::too_many_lines)]
fn emit_scalar_subquery(
b: &mut ProgramBuilder,
subquery: &SelectStatement,
reg: i32,
outer_ctx: &ScanCtx<'_>,
schema: &[TableSchema],
) {
let (columns, from, where_clause, group_by, having) = match &subquery.body.select {
SelectCore::Select {
columns,
from,
where_clause,
group_by,
having,
..
} => (columns, from, where_clause, group_by, having),
_ => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
}
};
// No-FROM scalar subquery: `(SELECT 1)`, `(SELECT 1 + 2)`.
if from.is_none() {
if let Some(ResultColumn::Expr { expr, .. }) = columns.first() {
emit_expr(b, expr, reg, Some(outer_ctx));
return;
}
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
}
let from_clause = from.as_ref().expect("checked is_none() above");
let (table_name, sub_alias) = match &from_clause.source {
fsqlite_ast::TableOrSubquery::Table { name, alias, .. } => (&name.name, alias.as_deref()),
_ => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
}
};
let table = match find_table(schema, table_name) {
Ok(t) => t,
Err(_) => {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
}
};
// Keep each scalar source open across outer rows without colliding with
// complex-IN or sibling scalar-subquery cursors.
let sub_cursor = b.alloc_aux_cursor_range(1);
let done_label = b.emit_label();
// Default: NULL (subquery returns no rows).
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
let open_done = b.emit_label();
b.emit_jump_to_label(Opcode::Once, 0, 0, open_done, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::OpenRead,
sub_cursor,
table.root_page as i32,
0,
P4::Int(table.columns.len() as i32),
0,
);
b.resolve_label(open_done);
let sub_ctx = ScanCtx {
cursor: sub_cursor,
table,
table_alias: sub_alias,
schema: Some(schema),
register_base: None,
secondaries: &[],
};
// Check if this is an aggregate query (e.g., SELECT MAX(x) FROM t).
let is_agg = has_aggregate_columns(columns);
if is_agg && group_by.is_empty() {
if is_simple_count_star(columns) && having.is_none() {
emit_scalar_count_star_subquery(b, &sub_ctx, outer_ctx, where_clause.as_deref(), reg);
} else {
// Simple aggregate subquery without GROUP BY:
// e.g., (SELECT COUNT(*) FROM t), (SELECT MAX(x) FROM t WHERE ...)
emit_scalar_aggregate_subquery(
b,
columns,
&sub_ctx,
outer_ctx,
where_clause.as_deref(),
reg,
done_label,
);
}
} else {
if let Some(where_expr) = where_clause
&& let Some((probe_expr, residual_terms)) =
extract_exists_rowid_probe(where_expr, table, sub_alias)
{
let probe_reg = b.alloc_temp();
emit_expr_with_fallback(b, probe_expr, probe_reg, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IsNull, probe_reg, 0, done_label, P4::None, 0);
b.emit_jump_to_label(
Opcode::SeekRowid,
sub_cursor,
probe_reg,
done_label,
P4::None,
0,
);
b.free_temp(probe_reg);
for residual_term in residual_terms {
let residual_reg = b.alloc_temp();
emit_expr_with_fallback(b, residual_term, residual_reg, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, residual_reg, 1, done_label, P4::None, 0);
b.free_temp(residual_reg);
}
emit_scalar_subquery_result_value(b, columns, reg, &sub_ctx, outer_ctx);
b.resolve_label(done_label);
return;
}
// GH #172: an ORDER BY (with optional LIMIT/OFFSET) selects a specific
// ordered row, not the first row in scan order. Route to a sorter-based
// path that materializes the matching rows, sorts by the ORDER BY terms,
// and reads the (OFFSET+1)-th result value. (An OFFSET without ORDER BY
// has an unspecified order in SQLite, so that keeps the first-row scan.)
if subquery.order_by.is_empty() {
// Non-aggregate scalar subquery: grab first row's first column value.
let rewind_addr = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, sub_cursor, 0, done_label, P4::None, 0);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (rewind_addr + 1) as i32;
// Apply WHERE filter.
let next_label = b.emit_label();
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
emit_expr_with_fallback(b, where_expr, r_cond, &sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, next_label, P4::None, 0);
b.free_temp(r_cond);
}
// Evaluate the first result column expression.
emit_scalar_subquery_result_value(b, columns, reg, &sub_ctx, outer_ctx);
// Got our value — jump to done (only need one row).
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next_label);
b.emit_op(Opcode::Next, sub_cursor, loop_body, 0, P4::None, 0);
} else {
emit_scalar_subquery_ordered(
b,
subquery,
columns,
sub_cursor,
&sub_ctx,
outer_ctx,
where_clause.as_deref(),
reg,
done_label,
);
}
}
b.resolve_label(done_label);
}
/// GH #172: emit an ordered non-aggregate scalar subquery.
///
/// Materializes every WHERE-matching row of the subquery into a sorter as
/// `[order_key_0, .., order_key_{n-1}, result_value]`, sorts by the ORDER BY
/// directions, skips `OFFSET` rows, and reads the result value of the next row
/// into `reg`. `reg` is left as its pre-set NULL default when the subquery
/// yields no row at the requested offset (empty match set, `OFFSET` past the
/// end, or `LIMIT 0`). The caller resolves `done_label`.
#[allow(
clippy::too_many_arguments,
clippy::cast_possible_truncation,
clippy::cast_possible_wrap
)]
fn emit_scalar_subquery_ordered(
b: &mut ProgramBuilder,
subquery: &SelectStatement,
columns: &[ResultColumn],
sub_cursor: i32,
sub_ctx: &ScanCtx<'_>,
outer_ctx: &ScanCtx<'_>,
where_clause: Option<&Expr>,
reg: i32,
done_label: crate::Label,
) {
let n_key = subquery.order_by.len();
// Sort-direction string: '+' ascending (default) / '-' descending per term.
let dir: String = subquery
.order_by
.iter()
.map(|term| match term.direction {
Some(SortDirection::Desc) => '-',
_ => '+',
})
.collect();
// LIMIT 0 short-circuits to NULL before any work.
if let Some(limit) = subquery.limit.as_ref() {
let r_lim = b.alloc_temp();
emit_expr(b, &limit.limit, r_lim, Some(outer_ctx));
// IfPos jumps (and decrements) when > 0; fall through means <= 0 -> NULL.
let lim_ok = b.emit_label();
b.emit_jump_to_label(Opcode::IfPos, r_lim, 0, lim_ok, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(lim_ok);
b.free_temp(r_lim);
}
let sorter_cursor = b.alloc_aux_cursor_range(1);
b.emit_op(
Opcode::SorterOpen,
sorter_cursor,
n_key as i32,
0,
P4::Str(dir),
0,
);
// Scan the subquery source, filter by WHERE, and insert
// [order_keys.., result_value] into the sorter.
let scan_done = b.emit_label();
let rewind_addr = b.current_addr();
b.emit_jump_to_label(Opcode::Rewind, sub_cursor, 0, scan_done, P4::None, 0);
let loop_body = (rewind_addr + 1) as i32;
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
emit_expr_with_fallback(b, where_expr, r_cond, sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, skip_label, P4::None, 0);
b.free_temp(r_cond);
}
let rec_regs = b.alloc_regs((n_key + 1) as i32);
for (j, term) in subquery.order_by.iter().enumerate() {
emit_expr_with_fallback(b, &term.expr, rec_regs + j as i32, sub_ctx, Some(outer_ctx));
}
emit_scalar_subquery_result_value(b, columns, rec_regs + n_key as i32, sub_ctx, outer_ctx);
let rec_reg = b.alloc_reg();
b.emit_op(
Opcode::MakeRecord,
rec_regs,
(n_key + 1) as i32,
rec_reg,
P4::None,
0,
);
b.emit_op(Opcode::SorterInsert, sorter_cursor, rec_reg, 0, P4::None, 0);
b.resolve_label(skip_label);
b.emit_op(Opcode::Next, sub_cursor, loop_body, 0, P4::None, 0);
b.resolve_label(scan_done);
// Sort; SorterSort jumps to `close` when the sorter is empty (NULL stays).
let close_label = b.emit_label();
b.emit_jump_to_label(
Opcode::SorterSort,
sorter_cursor,
0,
close_label,
P4::None,
0,
);
// Skip OFFSET rows (default 0). IfPos decrements r_off and jumps to
// `advance` while positive; falling through means offset exhausted -> read.
if let Some(offset_expr) = subquery.limit.as_ref().and_then(|l| l.offset.as_ref()) {
let r_off = b.alloc_temp();
emit_expr(b, offset_expr, r_off, Some(outer_ctx));
let skip_check = b.emit_label();
let advance = b.emit_label();
let read = b.emit_label();
b.resolve_label(skip_check);
b.emit_jump_to_label(Opcode::IfPos, r_off, 1, advance, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, read, P4::None, 0);
b.resolve_label(advance);
// SorterNext jumps to skip_check when another row exists; otherwise the
// sorter is exhausted mid-skip and the result stays NULL.
b.emit_jump_to_label(
Opcode::SorterNext,
sorter_cursor,
0,
skip_check,
P4::None,
0,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, close_label, P4::None, 0);
b.resolve_label(read);
b.free_temp(r_off);
}
// Materialize the current sorter record, then read the result value (the
// final field, at index n_key) into `reg`.
let scratch = b.alloc_reg();
b.emit_op(Opcode::SorterData, sorter_cursor, scratch, 0, P4::None, 0);
b.emit_op(
Opcode::Column,
sorter_cursor,
n_key as i32,
reg,
P4::None,
0,
);
b.resolve_label(close_label);
b.emit_op(Opcode::Close, sorter_cursor, 0, 0, P4::None, 0);
}
fn emit_scalar_count_star_subquery(
b: &mut ProgramBuilder,
sub_ctx: &ScanCtx<'_>,
outer_ctx: &ScanCtx<'_>,
where_clause: Option<&Expr>,
reg: i32,
) {
if where_clause.is_none() {
b.emit_op(Opcode::Count, sub_ctx.cursor, reg, 0, P4::None, 0);
return;
}
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
let finalize_label = b.emit_label();
let rewind_addr = b.current_addr();
b.emit_jump_to_label(
Opcode::Rewind,
sub_ctx.cursor,
0,
finalize_label,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (rewind_addr + 1) as i32;
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
emit_expr_with_fallback(b, where_expr, r_cond, sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, skip_label, P4::None, 0);
b.free_temp(r_cond);
}
b.emit_op(Opcode::AddImm, reg, 1, 0, P4::None, 0);
b.resolve_label(skip_label);
b.emit_op(Opcode::Next, sub_ctx.cursor, loop_body, 0, P4::None, 0);
b.resolve_label(finalize_label);
}
/// Emit bytecode for a simple aggregate scalar subquery (no GROUP BY).
///
/// Handles `(SELECT COUNT(*) FROM t)`, `(SELECT MAX(x) FROM t WHERE ...)`, etc.
fn emit_scalar_aggregate_subquery(
b: &mut ProgramBuilder,
columns: &[ResultColumn],
sub_ctx: &ScanCtx<'_>,
outer_ctx: &ScanCtx<'_>,
where_clause: Option<&Expr>,
reg: i32,
_done_label: crate::Label,
) {
// Parse the aggregate columns.
let Ok(agg_cols) = parse_aggregate_columns(columns, sub_ctx.table) else {
return;
};
if agg_cols.is_empty() {
return;
}
let accum_reg = b.alloc_temp();
b.emit_op(Opcode::Null, 0, accum_reg, 0, P4::None, 0);
let finalize_label = b.emit_label();
let rewind_addr = b.current_addr();
b.emit_jump_to_label(
Opcode::Rewind,
sub_ctx.cursor,
0,
finalize_label,
P4::None,
0,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let loop_body = (rewind_addr + 1) as i32;
// Apply WHERE filter.
let skip_label = b.emit_label();
if let Some(where_expr) = where_clause {
let r_cond = b.alloc_temp();
emit_expr_with_fallback(b, where_expr, r_cond, sub_ctx, Some(outer_ctx));
b.emit_jump_to_label(Opcode::IfNot, r_cond, 1, skip_label, P4::None, 0);
b.free_temp(r_cond);
}
// Separate real (hidden) aggregates from the output entry (which may
// have a wrapper_expr for complex expressions like COUNT(*) - 1).
let real_aggs: Vec<&AggColumn> = agg_cols.iter().filter(|a| !a.name.is_empty()).collect();
let output_entry = agg_cols.last();
// Allocate one accumulator per real aggregate.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let accum_base = if real_aggs.len() > 1 {
b.alloc_regs(real_aggs.len() as i32)
} else {
accum_reg
};
// Initialize all accumulators to NULL.
for i in 0..real_aggs.len() {
#[allow(clippy::cast_possible_wrap)]
b.emit_op(Opcode::Null, 0, accum_base + i as i32, 0, P4::None, 0);
}
// AggStep for each real aggregate in the loop body.
for (i, agg) in real_aggs.iter().enumerate() {
let total_args = agg.num_args.max(1);
let arg_base = b.alloc_regs(total_args);
if agg.num_args > 0 {
if agg.arg_is_rowid {
b.emit_op(Opcode::Rowid, sub_ctx.cursor, arg_base, 0, P4::None, 0);
} else if let Some(expr) = &agg.arg_expr {
emit_expr_with_fallback(b, expr, arg_base, sub_ctx, Some(outer_ctx));
} else if let Some(col_idx) = agg.arg_col_index {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
b.emit_op(
Opcode::Column,
sub_ctx.cursor,
col_idx as i32,
arg_base,
P4::None,
0,
);
}
for (j, extra_expr) in agg.extra_args.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let extra_reg = arg_base + 1 + j as i32;
emit_expr_with_fallback(b, extra_expr, extra_reg, sub_ctx, Some(outer_ctx));
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
{
let num_args = agg.num_args as u16;
let distinct_flag = i32::from(agg.distinct);
b.emit_op(
Opcode::AggStep,
distinct_flag,
arg_base,
accum_base + i as i32,
P4::FuncName(agg.name.clone()),
num_args,
);
}
}
b.resolve_label(skip_label);
b.emit_op(Opcode::Next, sub_ctx.cursor, loop_body, 0, P4::None, 0);
// Finalize all accumulators.
b.resolve_label(finalize_label);
for (i, agg) in real_aggs.iter().enumerate() {
#[allow(clippy::cast_possible_wrap)]
b.emit_op(
Opcode::AggFinal,
accum_base + i as i32,
agg.num_args,
0,
P4::FuncName(agg.name.clone()),
0,
);
}
// Evaluate wrapper expression or copy the single result.
if let Some(entry) = output_entry {
if let Some(ref wrapper) = entry.wrapper_expr {
if entry.multi_agg_indices.is_empty() {
// Simple wrapper (e.g. COUNT(*) - 1): evaluate the wrapper
// expression with __agg_result__ mapped to accum_reg.
emit_simple_agg_wrapper(b, wrapper, reg, accum_reg);
} else {
emit_multi_agg_wrapper(b, wrapper, reg, accum_base, &entry.multi_agg_indices);
}
} else {
b.emit_op(Opcode::Copy, accum_base, reg, 0, P4::None, 0);
}
} else {
b.emit_op(Opcode::Copy, accum_base, reg, 0, P4::None, 0);
}
if real_aggs.len() > 1 {
// Free the extra accumulators (accum_reg was already allocated).
}
b.free_temp(accum_reg);
}
/// Evaluate an expression with fallback context for correlated subqueries.
///
/// For column references, tries the inner (subquery) context first; if the
/// column doesn't belong to the inner table, falls back to the outer context.
/// For compound expressions, recurses so nested column refs get fallback logic.
fn fallback_declared_collation(
expr: &Expr,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
declared_collation_ctx(expr, Some(inner_ctx))
.or_else(|| outer_ctx.and_then(|outer| declared_collation_ctx(expr, Some(outer))))
.map(str::to_owned)
}
fn fallback_comparison_collation(
left: &Expr,
right: &Expr,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
extract_collation(left)
.or_else(|| extract_collation(right))
.map(str::to_owned)
.or_else(|| fallback_declared_collation(left, inner_ctx, outer_ctx))
.or_else(|| fallback_declared_collation(right, inner_ctx, outer_ctx))
}
fn fallback_effective_collation(
expr: &Expr,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
extract_collation(expr)
.map(str::to_owned)
.or_else(|| fallback_declared_collation(expr, inner_ctx, outer_ctx))
}
fn fallback_scalar_function_collation<'expr>(
args: impl IntoIterator<Item = &'expr Expr>,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
args.into_iter().find_map(|argument| {
extract_collation(argument)
.map(str::to_owned)
.or_else(|| fallback_declared_collation(argument, inner_ctx, outer_ctx))
})
}
fn emit_expr_with_fallback(
b: &mut ProgramBuilder,
expr: &Expr,
reg: i32,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) {
match expr {
Expr::BoundOuterValue { value, .. } => emit_sqlite_value(b, value, reg),
Expr::Column(col_ref, _) => {
if column_ref_resolves_in_ctx(col_ref, inner_ctx) {
emit_expr(b, expr, reg, Some(inner_ctx));
} else if let Some(outer) =
outer_ctx.filter(|outer| column_ref_resolves_in_ctx(col_ref, outer))
{
emit_expr(b, expr, reg, Some(outer));
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
}
Expr::BinaryOp {
left, op, right, ..
} => {
// Recursively resolve column refs in children, then apply the op.
let r_left = b.alloc_temp();
let r_right = b.alloc_temp();
emit_expr_with_fallback(b, left, r_left, inner_ctx, outer_ctx);
emit_expr_with_fallback(b, right, r_right, inner_ctx, outer_ctx);
if matches!(
op,
fsqlite_ast::BinaryOp::Eq
| fsqlite_ast::BinaryOp::Ne
| fsqlite_ast::BinaryOp::Lt
| fsqlite_ast::BinaryOp::Le
| fsqlite_ast::BinaryOp::Gt
| fsqlite_ast::BinaryOp::Ge
) {
let cmp_opcode = match op {
fsqlite_ast::BinaryOp::Eq => Opcode::Eq,
fsqlite_ast::BinaryOp::Ne => Opcode::Ne,
fsqlite_ast::BinaryOp::Lt => Opcode::Lt,
fsqlite_ast::BinaryOp::Le => Opcode::Le,
fsqlite_ast::BinaryOp::Gt => Opcode::Gt,
fsqlite_ast::BinaryOp::Ge => Opcode::Ge,
_ => unreachable!(),
};
// SQL three-valued logic: if either operand is NULL, result is NULL.
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
let comparison_collation =
fallback_comparison_collation(left, right, inner_ctx, outer_ctx)
.map_or(P4::None, P4::Collation);
let comparison_affinity = combine_comparison_affinity(
fallback_expr_affinity(left, inner_ctx, outer_ctx),
fallback_expr_affinity(right, inner_ctx, outer_ctx),
);
b.emit_jump_to_label(Opcode::IsNull, r_left, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_right, 0, null_label, P4::None, 0);
b.emit_jump_to_label(
cmp_opcode,
r_right,
r_left,
true_label,
comparison_collation,
comparison_affinity,
);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
} else if matches!(op, fsqlite_ast::BinaryOp::Is | fsqlite_ast::BinaryOp::IsNot) {
if let Some((p3, p4)) = is_true_false_params(*op, right) {
b.emit_op(Opcode::IsTrue, r_left, reg, p3, p4, 0);
} else {
let (cmp_opcode, flag) = match op {
fsqlite_ast::BinaryOp::Is => (Opcode::Eq, 0x80_u16),
_ => (Opcode::Ne, 0x80_u16),
};
let true_label = b.emit_label();
let done_label = b.emit_label();
let comparison_collation =
fallback_comparison_collation(left, right, inner_ctx, outer_ctx)
.map_or(P4::None, P4::Collation);
let comparison_affinity = combine_comparison_affinity(
fallback_expr_affinity(left, inner_ctx, outer_ctx),
fallback_expr_affinity(right, inner_ctx, outer_ctx),
);
b.emit_jump_to_label(
cmp_opcode,
r_right,
r_left,
true_label,
comparison_collation,
flag | comparison_affinity,
);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.resolve_label(done_label);
}
} else {
// Arithmetic / logical / bitwise.
// VDBE convention: P3 = P2 op P1 (P1=rhs, P2=lhs).
let vdbe_op = binary_op_to_opcode(*op);
b.emit_op(vdbe_op, r_right, r_left, reg, P4::None, 0);
}
b.free_temp(r_left);
b.free_temp(r_right);
}
Expr::UnaryOp {
op, expr: inner, ..
} => {
emit_expr_with_fallback(b, inner, reg, inner_ctx, outer_ctx);
match op {
fsqlite_ast::UnaryOp::Negate => {
let tmp = b.alloc_temp();
b.emit_op(Opcode::Integer, -1, tmp, 0, P4::None, 0);
b.emit_op(Opcode::Multiply, tmp, reg, reg, P4::None, 0);
b.free_temp(tmp);
}
fsqlite_ast::UnaryOp::Plus => {}
fsqlite_ast::UnaryOp::BitNot => {
b.emit_op(Opcode::BitNot, reg, reg, 0, P4::None, 0);
}
fsqlite_ast::UnaryOp::Not => {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
}
// ── BETWEEN ─────────────────────────────────────────────────────
Expr::Between {
expr: operand,
low,
high,
not,
..
} => {
let r_operand = b.alloc_temp();
let r_low = b.alloc_temp();
let r_high = b.alloc_temp();
emit_expr_with_fallback(b, operand, r_operand, inner_ctx, outer_ctx);
emit_expr_with_fallback(b, low, r_low, inner_ctx, outer_ctx);
emit_expr_with_fallback(b, high, r_high, inner_ctx, outer_ctx);
let low_collation = fallback_comparison_collation(operand, low, inner_ctx, outer_ctx)
.map_or(P4::None, P4::Collation);
let high_collation = fallback_comparison_collation(operand, high, inner_ctx, outer_ctx)
.map_or(P4::None, P4::Collation);
let low_affinity = combine_comparison_affinity(
fallback_expr_affinity(operand, inner_ctx, outer_ctx),
fallback_expr_affinity(low, inner_ctx, outer_ctx),
);
let high_affinity = combine_comparison_affinity(
fallback_expr_affinity(operand, inner_ctx, outer_ctx),
fallback_expr_affinity(high, inner_ctx, outer_ctx),
);
let false_label = b.emit_label();
let null_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_operand, 0, null_label, P4::None, 0);
b.emit_jump_to_label(
Opcode::Lt,
r_low,
r_operand,
false_label,
low_collation,
low_affinity,
);
b.emit_jump_to_label(
Opcode::Gt,
r_high,
r_operand,
false_label,
high_collation,
high_affinity,
);
b.emit_jump_to_label(Opcode::IsNull, r_low, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_high, 0, null_label, P4::None, 0);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(false_label);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_high);
b.free_temp(r_low);
b.free_temp(r_operand);
}
// ── LIKE / GLOB ────────────────────────────────────────────────
Expr::Like {
expr: operand,
pattern,
not,
escape,
op: like_op,
..
} => {
if use_builtin_like_glob_semantics()
&& matches!(like_op, fsqlite_ast::LikeOp::Like)
&& escape.is_none()
&& let Expr::Literal(Literal::String(pattern_text), _) = pattern.as_ref()
&& let Some((kind, literal)) = classify_sql_like_fast_path(pattern_text, None)
{
emit_expr_with_fallback(b, operand, reg, inner_ctx, outer_ctx);
b.emit_op(
Opcode::LikeConstFast,
reg,
reg,
kind.opcode_tag(),
P4::Str(literal.to_owned()),
u16::from(*not),
);
return;
}
let func_name = match like_op {
fsqlite_ast::LikeOp::Like => "LIKE",
fsqlite_ast::LikeOp::Glob => "GLOB",
fsqlite_ast::LikeOp::Match => "MATCH",
fsqlite_ast::LikeOp::Regexp => "REGEXP",
};
let nargs: u16 = if escape.is_some() { 3 } else { 2 };
let arg_base = b.alloc_regs(i32::from(nargs));
// like(pattern, string [, escape])
emit_expr_with_fallback(b, pattern, arg_base, inner_ctx, outer_ctx);
emit_expr_with_fallback(b, operand, arg_base + 1, inner_ctx, outer_ctx);
if let Some(esc) = escape {
emit_expr_with_fallback(b, esc, arg_base + 2, inner_ctx, outer_ctx);
}
let function_p4 =
if scalar_consumes_argument_collation_for_codegen(func_name, i32::from(nargs)) {
fallback_scalar_function_collation(
[pattern.as_ref(), operand.as_ref()]
.into_iter()
.chain(escape.as_deref()),
inner_ctx,
outer_ctx,
)
.map_or_else(
|| P4::FuncName(func_name.to_owned()),
|collation| P4::FuncNameCollated(func_name.to_owned(), collation),
)
} else {
P4::FuncName(func_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, nargs);
if *not {
b.emit_op(Opcode::Not, reg, reg, 0, P4::None, 0);
}
}
// ── JSON extraction operators ─────────────────────────────────
Expr::JsonAccess {
expr: inner,
path,
arrow,
..
} => {
let arg_base = b.alloc_regs(2);
emit_expr_with_fallback(b, inner, arg_base, inner_ctx, outer_ctx);
emit_expr_with_fallback(b, path, arg_base + 1, inner_ctx, outer_ctx);
let function_name = json_access_func_name(*arrow);
let function_p4 = if scalar_consumes_argument_collation_for_codegen(function_name, 2) {
fallback_scalar_function_collation(
[inner.as_ref(), path.as_ref()],
inner_ctx,
outer_ctx,
)
.map_or_else(
|| P4::FuncName(function_name.to_owned()),
|collation| P4::FuncNameCollated(function_name.to_owned(), collation),
)
} else {
P4::FuncName(function_name.to_owned())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, 2);
}
// ── Function call ──────────────────────────────────────────────
Expr::FunctionCall { name, args, .. } => {
let canon = name.to_ascii_uppercase();
match args {
fsqlite_ast::FunctionArgs::Star => {
b.emit_op(Opcode::PureFunc, 0, 0, reg, P4::FuncName(canon), 0);
}
fsqlite_ast::FunctionArgs::List(arg_list) => {
if try_emit_column_substr_prefix(b, name, arg_list, reg, inner_ctx)
|| try_emit_column_octet_length(b, name, arg_list, reg, inner_ctx)
|| outer_ctx.is_some_and(|outer| {
try_emit_column_substr_prefix(b, name, arg_list, reg, outer)
|| try_emit_column_octet_length(b, name, arg_list, reg, outer)
})
{
return;
}
let Ok(nargs) = u16::try_from(arg_list.len()) else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
return;
};
let arg_base = b.alloc_regs(i32::from(nargs));
for (i, arg_expr) in arg_list.iter().enumerate() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
emit_expr_with_fallback(
b,
arg_expr,
arg_base + i as i32,
inner_ctx,
outer_ctx,
);
}
let function_p4 =
if scalar_consumes_argument_collation_for_codegen(&canon, i32::from(nargs))
{
fallback_scalar_function_collation(arg_list, inner_ctx, outer_ctx)
.map_or_else(
|| P4::FuncName(canon.clone()),
|collation| P4::FuncNameCollated(canon.clone(), collation),
)
} else {
P4::FuncName(canon.clone())
};
b.emit_op(Opcode::PureFunc, 0, arg_base, reg, function_p4, nargs);
}
}
}
// ── CASE ───────────────────────────────────────────────────────
Expr::Case {
operand,
whens,
else_expr,
..
} => {
let done_label = b.emit_label();
if let Some(op_expr) = operand {
let r_op = b.alloc_temp();
emit_expr_with_fallback(b, op_expr, r_op, inner_ctx, outer_ctx);
let op_aff = fallback_expr_affinity(op_expr, inner_ctx, outer_ctx);
for (when_expr, then_expr) in whens {
let r_when = b.alloc_temp();
emit_expr_with_fallback(b, when_expr, r_when, inner_ctx, outer_ctx);
let next = b.emit_label();
// Simple CASE desugars to `operand = when`; apply comparison
// affinity like a plain `=` would (bd-w4r25).
let cmp_aff = combine_comparison_affinity(
op_aff,
fallback_expr_affinity(when_expr, inner_ctx, outer_ctx),
);
let comparison_collation =
fallback_comparison_collation(op_expr, when_expr, inner_ctx, outer_ctx)
.map_or(P4::None, P4::Collation);
b.emit_jump_to_label(Opcode::IsNull, r_op, 0, next, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_when, 0, next, P4::None, 0);
b.emit_jump_to_label(
Opcode::Ne,
r_when,
r_op,
next,
comparison_collation,
cmp_aff,
);
b.free_temp(r_when);
emit_expr_with_fallback(b, then_expr, reg, inner_ctx, outer_ctx);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next);
}
b.free_temp(r_op);
} else {
for (when_expr, then_expr) in whens {
// Searched CASE WHEN is a TRUTH context: short-circuit AND/OR
// left-to-right so a would-be-skipped erroring operand is
// never evaluated (bd-and-or-short-circuit-value-jump-gaps-dkswh
// GAP-2). Non-AND/OR conditions emit byte-identically.
let next = b.emit_label();
emit_searched_case_when_condition_with_fallback(
b, when_expr, next, inner_ctx, outer_ctx,
);
emit_expr_with_fallback(b, then_expr, reg, inner_ctx, outer_ctx);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next);
}
}
if let Some(el) = else_expr {
emit_expr_with_fallback(b, el, reg, inner_ctx, outer_ctx);
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
b.resolve_label(done_label);
}
// ── IN (list) ──────────────────────────────────────────────────
Expr::In {
expr: operand,
set,
not,
..
} => {
if let fsqlite_ast::InSet::List(values) = set {
if values.is_empty() {
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
} else {
let r_op = b.alloc_temp();
emit_expr_with_fallback(b, operand, r_op, inner_ctx, outer_ctx);
// Apply the operand's affinity to each list value (bd-cfmf6/bd-56aj2).
let in_aff = combine_comparison_affinity(
fallback_expr_affinity(operand, inner_ctx, outer_ctx),
b'A',
);
let found_label = b.emit_label();
let done_label = b.emit_label();
let null_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_op, 0, null_label, P4::None, 0);
let in_collation =
if values.len() == 1 && singleton_in_rhs_is_constant(&values[0]) {
fallback_comparison_collation(operand, &values[0], inner_ctx, outer_ctx)
} else {
fallback_effective_collation(operand, inner_ctx, outer_ctx)
};
let comparison_p4 = in_collation.map_or(P4::None, P4::Collation);
let r_saw_null = b.alloc_temp();
b.emit_op(Opcode::Integer, 0, r_saw_null, 0, P4::None, 0);
let r_val = b.alloc_temp();
for val in values {
emit_expr_with_fallback(b, val, r_val, inner_ctx, outer_ctx);
b.emit_jump_to_label(
Opcode::Eq,
r_val,
r_op,
found_label,
comparison_p4.clone(),
in_aff,
);
let next_value = b.emit_label();
let mark_null = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_val, 0, mark_null, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, next_value, P4::None, 0);
b.resolve_label(mark_null);
b.emit_op(Opcode::Integer, 1, r_saw_null, 0, P4::None, 0);
b.resolve_label(next_value);
}
b.free_temp(r_val);
b.emit_jump_to_label(Opcode::If, r_saw_null, 0, null_label, P4::None, 0);
b.free_temp(r_saw_null);
b.emit_op(Opcode::Integer, i32::from(*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(found_label);
b.emit_op(Opcode::Integer, i32::from(!*not), reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_op);
}
} else {
// Subquery / table IN — delegate to inner context.
emit_expr(b, expr, reg, Some(inner_ctx));
}
}
// ── CAST ───────────────────────────────────────────────────────
Expr::Cast {
expr: inner,
type_name,
..
} => {
emit_expr_with_fallback(b, inner, reg, inner_ctx, outer_ctx);
let affinity = type_name_to_affinity(type_name);
if affinity != 0 {
b.emit_op(
Opcode::Affinity,
reg,
1,
0,
P4::Str(String::from(affinity as char)),
0,
);
}
}
// ── IS [NOT] NULL ──────────────────────────────────────────────
Expr::IsNull {
expr: inner, not, ..
} => {
emit_expr_with_fallback(b, inner, reg, inner_ctx, outer_ctx);
let lbl_null = b.emit_label();
let lbl_done = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, reg, 0, lbl_null, P4::None, 0);
let val_not_null = i32::from(*not);
let val_null = i32::from(!*not);
b.emit_op(Opcode::Integer, val_not_null, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, lbl_done, P4::None, 0);
b.resolve_label(lbl_null);
b.emit_op(Opcode::Integer, val_null, reg, 0, P4::None, 0);
b.resolve_label(lbl_done);
}
// ── Collate ────────────────────────────────────────────────────
Expr::Collate { expr: inner, .. } => {
emit_expr_with_fallback(b, inner, reg, inner_ctx, outer_ctx);
}
// ── Remaining types: delegate to inner context ─────────────────
_ => {
emit_expr(b, expr, reg, Some(inner_ctx));
}
}
}
/// Check if a column reference resolves in a given scan context.
fn resolve_column_in_ctx(col_ref: &ColumnRef, ctx: &ScanCtx<'_>) -> Option<usize> {
// Qualified: table.column (case-insensitive per SQL standard)
if let Some(ref table_name) = col_ref.table {
// When the table has an alias, only the alias matches qualified
// references. This prevents `inventory.col` from resolving to
// `FROM inventory i2` — only `i2.col` should match.
let table_match = if let Some(alias) = ctx.table_alias {
table_name.eq_ignore_ascii_case(alias)
} else {
table_name.eq_ignore_ascii_case(&ctx.table.name)
};
if table_match {
return ctx
.table
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(&col_ref.column));
}
return None;
}
// Unqualified: just column name
ctx.table
.columns
.iter()
.position(|c| c.name.eq_ignore_ascii_case(&col_ref.column))
}
fn column_ref_resolves_in_ctx(col_ref: &ColumnRef, ctx: &ScanCtx<'_>) -> bool {
if resolve_column_in_ctx(col_ref, ctx).is_some() {
return true;
}
if let Some(qualifier) = col_ref.table.as_deref() {
if matches_table_or_alias(qualifier, ctx.table, ctx.table_alias) {
return ctx.table.resolves_to_hidden_rowid(&col_ref.column);
}
return ctx.secondaries.iter().any(|secondary| {
matches_table_or_alias(qualifier, secondary.table, secondary.table_alias)
&& table_has_column_or_rowid(secondary.table, &col_ref.column)
});
}
ctx.table.resolves_to_hidden_rowid(&col_ref.column)
|| ctx
.secondaries
.iter()
.any(|secondary| table_has_column_or_rowid(secondary.table, &col_ref.column))
}
/// Map an AST `BinaryOp` to the corresponding VDBE opcode.
fn binary_op_to_opcode(op: fsqlite_ast::BinaryOp) -> Opcode {
match op {
fsqlite_ast::BinaryOp::Add => Opcode::Add,
fsqlite_ast::BinaryOp::Subtract => Opcode::Subtract,
fsqlite_ast::BinaryOp::Multiply => Opcode::Multiply,
fsqlite_ast::BinaryOp::Divide => Opcode::Divide,
fsqlite_ast::BinaryOp::Modulo => Opcode::Remainder,
fsqlite_ast::BinaryOp::Concat => Opcode::Concat,
fsqlite_ast::BinaryOp::BitAnd => Opcode::BitAnd,
fsqlite_ast::BinaryOp::BitOr => Opcode::BitOr,
fsqlite_ast::BinaryOp::ShiftLeft => Opcode::ShiftLeft,
fsqlite_ast::BinaryOp::ShiftRight => Opcode::ShiftRight,
fsqlite_ast::BinaryOp::And => Opcode::And,
fsqlite_ast::BinaryOp::Or => Opcode::Or,
// Comparison ops use jump instructions; map to Eq as placeholder.
fsqlite_ast::BinaryOp::Eq
| fsqlite_ast::BinaryOp::Ne
| fsqlite_ast::BinaryOp::Lt
| fsqlite_ast::BinaryOp::Le
| fsqlite_ast::BinaryOp::Gt
| fsqlite_ast::BinaryOp::Ge
| fsqlite_ast::BinaryOp::Is
| fsqlite_ast::BinaryOp::IsNot => Opcode::Eq, // handled separately
}
}
/// Emit bytecode for a binary operation expression.
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_binary_op(
b: &mut ProgramBuilder,
left: &Expr,
op: fsqlite_ast::BinaryOp,
right: &Expr,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
// For comparison operators, emit a conditional jump pattern that
// produces 1 (true) or 0 (false) as an integer result.
if matches!(
op,
fsqlite_ast::BinaryOp::Eq
| fsqlite_ast::BinaryOp::Ne
| fsqlite_ast::BinaryOp::Lt
| fsqlite_ast::BinaryOp::Le
| fsqlite_ast::BinaryOp::Gt
| fsqlite_ast::BinaryOp::Ge
) {
emit_comparison(b, left, op, right, reg, ctx);
return;
}
if matches!(op, fsqlite_ast::BinaryOp::Is | fsqlite_ast::BinaryOp::IsNot) {
emit_is_comparison(b, left, op, right, reg, ctx);
return;
}
// Arithmetic / logical / bitwise: evaluate left into reg, right into tmp,
// then apply the opcode.
let tmp = b.alloc_temp();
emit_expr(b, left, reg, ctx);
emit_expr(b, right, tmp, ctx);
let opcode = binary_op_to_opcode(op);
// VDBE arithmetic: OP p1=rhs, p2=lhs, p3=dest
b.emit_op(opcode, tmp, reg, reg, P4::None, 0);
b.free_temp(tmp);
}
/// Extract the leftmost explicit COLLATE from an expression operand.
///
/// SQLite treats a COLLATE operator anywhere inside a comparison operand as
/// explicit for that operand; it is not limited to a wrapper at the root.
/// Nested SELECT scopes and window-control clauses are deliberately excluded:
/// their collations do not become collations of the containing scalar value.
fn extract_collation(expr: &Expr) -> Option<&str> {
match expr {
Expr::Collate { collation, .. } => Some(collation.as_str()),
Expr::BinaryOp { left, right, .. }
| Expr::JsonAccess {
expr: left,
path: right,
..
} => extract_collation(left).or_else(|| extract_collation(right)),
Expr::UnaryOp { expr, .. } | Expr::Cast { expr, .. } | Expr::IsNull { expr, .. } => {
extract_collation(expr)
}
Expr::Between {
expr, low, high, ..
} => extract_collation(expr)
.or_else(|| extract_collation(low))
.or_else(|| extract_collation(high)),
Expr::In { expr, set, .. } => extract_collation(expr).or_else(|| match set {
InSet::List(values) => values.iter().find_map(extract_collation),
InSet::Table(_) | InSet::Subquery(_) => None,
}),
Expr::Like {
expr,
pattern,
escape,
..
} => extract_collation(pattern)
.or_else(|| extract_collation(expr))
.or_else(|| escape.as_deref().and_then(extract_collation)),
Expr::Case {
operand,
whens,
else_expr,
..
} => operand
.as_deref()
.and_then(extract_collation)
.or_else(|| {
whens.iter().find_map(|(when_expr, then_expr)| {
extract_collation(when_expr).or_else(|| extract_collation(then_expr))
})
})
.or_else(|| else_expr.as_deref().and_then(extract_collation)),
Expr::FunctionCall { args, .. } => match args {
FunctionArgs::Star => None,
FunctionArgs::List(values) => values.iter().find_map(extract_collation),
},
Expr::RowValue(values, _) => values.iter().find_map(extract_collation),
Expr::Literal(..)
| Expr::BoundOuterValue { .. }
| Expr::Column(..)
| Expr::Exists { .. }
| Expr::Subquery(..)
| Expr::Raise { .. }
| Expr::Placeholder(..) => None,
}
}
fn join_declared_collation<'a>(
expr: &'a Expr,
tables: &[(&'a TableSchema, Option<&str>)],
) -> Option<&'a str> {
let source = declared_collation_source_expr(expr);
if let Expr::BoundOuterValue { collation, .. } = source {
return collation.as_name();
}
let Expr::Column(col_ref, _) = source else {
return None;
};
tables.iter().find_map(|(table, alias)| {
if let Some(qualifier) = col_ref.table.as_deref()
&& !matches_table_or_alias(qualifier, table, *alias)
{
return None;
}
if let Some(index) = table.column_index(&col_ref.column) {
return Some(
table.columns[index]
.collation
.as_deref()
.unwrap_or("BINARY"),
);
}
table
.resolves_to_hidden_rowid(&col_ref.column)
.then_some("BINARY")
})
}
fn join_comparison_collation_name<'a>(
left: &'a Expr,
right: &'a Expr,
tables: &[(&'a TableSchema, Option<&'a str>)],
) -> Option<&'a str> {
extract_collation(left)
.or_else(|| extract_collation(right))
.or_else(|| join_declared_collation(left, tables))
.or_else(|| join_declared_collation(right, tables))
}
fn join_expr_affinity(expr: &Expr, tables: &[(&TableSchema, Option<&str>)]) -> u8 {
let inner = strip_collate_wrappers(expr);
match inner {
Expr::BoundOuterValue { affinity, .. } => bound_outer_affinity_code(*affinity),
Expr::Column(col_ref, _) => tables
.iter()
.find_map(|(table, alias)| {
if let Some(qualifier) = col_ref.table.as_deref()
&& !matches_table_or_alias(qualifier, table, *alias)
{
return None;
}
if let Some(index) = table.column_index(&col_ref.column) {
return Some(schema_column_expr_affinity(&table.columns[index]));
}
table
.resolves_to_hidden_rowid(&col_ref.column)
.then_some(b'D')
})
.unwrap_or(b'A'),
Expr::Cast { type_name, .. } => type_name_to_affinity(type_name),
Expr::Subquery(select, _) => {
scalar_subquery_affinity_in_join(select, tables).unwrap_or(b'A')
}
_ => b'A',
}
}
fn join_comparison_affinity_p5(
left: &Expr,
right: &Expr,
tables: &[(&TableSchema, Option<&str>)],
) -> u16 {
let left_affinity = join_expr_affinity(left, tables);
let right_affinity = join_expr_affinity(right, tables);
let is_numeric = |affinity: u8| matches!(affinity, b'C' | b'D' | b'E');
if is_numeric(left_affinity) && matches!(right_affinity, b'A' | b'B') {
return u16::from(b'C');
}
if is_numeric(right_affinity) && matches!(left_affinity, b'A' | b'B') {
return u16::from(b'C');
}
if (left_affinity == b'B' && right_affinity == b'A')
|| (left_affinity == b'A' && right_affinity == b'B')
{
return u16::from(b'B');
}
0
}
/// Extract explicit COLLATE from an ORDER BY term's expression.
#[allow(dead_code)]
fn extract_collation_from_ordering_term(term: &OrderingTerm) -> Option<&str> {
extract_collation(&term.expr)
}
/// Get the column-level collation for an expression from the table schema.
/// Only checks schema-declared collation (e.g. `TEXT COLLATE NOCASE`), NOT
/// explicit COLLATE wrappers on the expression — use `extract_collation` for that.
/// Returns `None` for BINARY (the default) or when the expression is not a column ref.
fn column_collation<'a>(
expr: &'a Expr,
table: &'a TableSchema,
table_alias: Option<&str>,
) -> Option<&'a str> {
let inner = declared_collation_source_expr(expr);
if let Expr::BoundOuterValue { collation, .. } = inner {
return collation
.as_name()
.filter(|collation| !collation.eq_ignore_ascii_case("BINARY"));
}
if let Expr::Column(col_ref, _) = inner {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, table_alias)
{
return None;
}
if let Some(idx) = table.column_index(&col_ref.column) {
return table.columns[idx].collation.as_deref();
}
}
None
}
/// Resolve the declared collation of the column value carried by `expr`.
///
/// The outer `Option` is deliberately significant: a resolved column with no
/// named declaration still defines the BINARY collation and must stop SQLite's
/// precedence search. Returning `None` means that the expression does not
/// derive its collation from a column in this scan scope.
fn declared_collation_ctx<'a>(expr: &'a Expr, ctx: Option<&'a ScanCtx<'_>>) -> Option<&'a str> {
let source = declared_collation_source_expr(expr);
if let Expr::BoundOuterValue { collation, .. } = source {
return collation.as_name();
}
let ctx = ctx?;
let Expr::Column(col_ref, _) = source else {
return None;
};
let in_table = |table: &'a TableSchema, alias: Option<&str>| -> Option<&'a str> {
if col_ref
.table
.as_deref()
.is_some_and(|qualifier| !matches_table_or_alias(qualifier, table, alias))
{
return None;
}
if let Some(index) = table.column_index(&col_ref.column) {
return Some(
table.columns[index]
.collation
.as_deref()
.unwrap_or("BINARY"),
);
}
table
.resolves_to_hidden_rowid(&col_ref.column)
.then_some("BINARY")
};
if col_ref.table.is_some() {
return in_table(ctx.table, ctx.table_alias).or_else(|| {
ctx.secondaries
.iter()
.find_map(|secondary| in_table(secondary.table, secondary.table_alias))
});
}
let mut winner = in_table(ctx.table, ctx.table_alias);
for secondary in ctx.secondaries {
if let Some(collation) = in_table(secondary.table, secondary.table_alias) {
if winner.is_some() {
// Validation reports the ambiguous column before execution.
// Do not guess a collation if this defensive path is reached.
return None;
}
winner = Some(collation);
}
}
winner
}
/// Resolve a comparison collation with SQLite precedence: an explicit
/// COLLATE on either operand (left wins a tie), then a declared collation on
/// either operand (again left first). BINARY remains an explicit winner so a
/// later named declaration cannot override it.
fn comparison_collation_ctx(
left: &Expr,
right: &Expr,
ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
extract_collation(left)
.or_else(|| extract_collation(right))
.map(str::to_owned)
.or_else(|| declared_collation_ctx(left, ctx).map(str::to_owned))
.or_else(|| declared_collation_ctx(right, ctx).map(str::to_owned))
}
/// NULLIF and scalar MIN/MAX choose the first argument that defines a
/// collation. A bare BINARY column therefore stops the search before a later
/// argument's explicit or declared named collation.
fn scalar_function_argument_collation_ctx<'expr>(
args: impl IntoIterator<Item = &'expr Expr>,
ctx: Option<&ScanCtx<'_>>,
) -> Option<String> {
args.into_iter().find_map(|argument| {
extract_collation(argument)
.map(str::to_owned)
.or_else(|| declared_collation_ctx(argument, ctx).map(str::to_owned))
})
}
/// Reach a column whose declared collation is inherited by this expression.
///
/// SQLite preserves a column's collation through COLLATE (when the explicit
/// wrapper itself is being inspected separately), CAST, and unary plus. Other
/// unary operators produce a new expression with no declared column
/// collation.
fn declared_collation_source_expr(expr: &Expr) -> &Expr {
match expr {
Expr::Collate { expr, .. }
| Expr::Cast { expr, .. }
| Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Plus,
expr,
..
} => declared_collation_source_expr(expr),
_ => expr,
}
}
fn bound_outer_declared_collation(expr: &Expr) -> Option<&str> {
let Expr::BoundOuterValue { collation, .. } = declared_collation_source_expr(expr) else {
return None;
};
collation.as_name()
}
/// Get effective collation via `ScanCtx`: explicit COLLATE first, then column-level.
fn effective_collation_ctx<'a>(expr: &'a Expr, ctx: Option<&'a ScanCtx<'a>>) -> Option<&'a str> {
if let Some(coll) = extract_collation(expr) {
return Some(coll);
}
declared_collation_ctx(expr, ctx).filter(|collation| !collation.eq_ignore_ascii_case("BINARY"))
}
/// Emit a comparison expression that produces 1 (true) or 0 (false).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_comparison(
b: &mut ProgramBuilder,
left: &Expr,
op: fsqlite_ast::BinaryOp,
right: &Expr,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
let r_left = b.alloc_temp();
let r_right = b.alloc_temp();
emit_expr(b, left, r_left, ctx);
emit_expr(b, right, r_right, ctx);
let cmp_opcode = match op {
fsqlite_ast::BinaryOp::Eq => Some(Opcode::Eq),
fsqlite_ast::BinaryOp::Ne => Some(Opcode::Ne),
fsqlite_ast::BinaryOp::Lt => Some(Opcode::Lt),
fsqlite_ast::BinaryOp::Le => Some(Opcode::Le),
fsqlite_ast::BinaryOp::Gt => Some(Opcode::Gt),
fsqlite_ast::BinaryOp::Ge => Some(Opcode::Ge),
_ => None,
};
let Some(cmp_opcode) = cmp_opcode else {
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.free_temp(r_right);
b.free_temp(r_left);
return;
};
// Check for COLLATE on either operand, then column-level collation.
let p4 = comparison_collation_ctx(left, right, ctx).map_or(P4::None, P4::Collation);
// SQL three-valued logic: if either operand is NULL, the result is NULL.
// Check for NULL before the comparison.
let null_label = b.emit_label();
let true_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::IsNull, r_left, 0, null_label, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_right, 0, null_label, P4::None, 0);
// Compute comparison affinity for TEXT↔numeric coercion (SQLite §4.2).
let cmp_aff = comparison_affinity_p5(left, right, ctx);
// Comparison: p1=rhs_reg, p2=jump_target (label), p3=lhs_reg
b.emit_jump_to_label(cmp_opcode, r_right, r_left, true_label, p4, cmp_aff);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(null_label);
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_right);
b.free_temp(r_left);
}
/// Emit IS / IS NOT comparison (NULLEQ semantics).
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
fn emit_is_comparison(
b: &mut ProgramBuilder,
left: &Expr,
op: fsqlite_ast::BinaryOp,
right: &Expr,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
// IS TRUE / IS FALSE / IS NOT TRUE / IS NOT FALSE → IsTrue opcode.
if let Some((p3, p4)) = is_true_false_params(op, right) {
let r_left = b.alloc_temp();
emit_expr(b, left, r_left, ctx);
b.emit_op(Opcode::IsTrue, r_left, reg, p3, p4, 0);
b.free_temp(r_left);
return;
}
let r_left = b.alloc_temp();
let r_right = b.alloc_temp();
emit_expr(b, left, r_left, ctx);
emit_expr(b, right, r_right, ctx);
let true_label = b.emit_label();
let done_label = b.emit_label();
// IS uses Eq with NULLEQ flag (p5=0x80). IS NOT uses Ne with NULLEQ.
let cmp_and_flag = match op {
fsqlite_ast::BinaryOp::Is => Some((Opcode::Eq, 0x80_u16)),
fsqlite_ast::BinaryOp::IsNot => Some((Opcode::Ne, 0x80_u16)),
_ => None,
};
let Some((cmp_opcode, nulleq_flag)) = cmp_and_flag else {
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.free_temp(r_right);
b.free_temp(r_left);
return;
};
let p4 = comparison_collation_ctx(left, right, ctx).map_or(P4::None, P4::Collation);
let comparison_affinity = comparison_affinity_p5(left, right, ctx);
b.emit_jump_to_label(
cmp_opcode,
r_right,
r_left,
true_label,
p4,
nulleq_flag | comparison_affinity,
);
b.emit_op(Opcode::Integer, 0, reg, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(true_label);
b.emit_op(Opcode::Integer, 1, reg, 0, P4::None, 0);
b.resolve_label(done_label);
b.free_temp(r_right);
b.free_temp(r_left);
}
/// Check if a `BinaryOp::Is/IsNot` with `Literal::True/False` should use
/// the `IsTrue` opcode. Returns `(p3, p4)` for the IsTrue instruction.
///
/// Mapping:
/// IS TRUE → (p3=0, P4::None) — truthy, NULL→0
/// IS FALSE → (p3=1, P4::Int(1)) — !truthy, NULL→0
/// IS NOT TRUE → (p3=0, P4::Int(1)) — !truthy, NULL→1
/// IS NOT FALSE → (p3=1, P4::None) — truthy inverted, NULL→1
fn is_true_false_params(op: fsqlite_ast::BinaryOp, rhs: &Expr) -> Option<(i32, P4)> {
let is_not = matches!(op, fsqlite_ast::BinaryOp::IsNot);
match rhs {
Expr::Literal(Literal::True, _) => {
if is_not {
// IS NOT TRUE: p3=0, p4=1
Some((0, P4::Int(1)))
} else {
// IS TRUE: p3=0, p4=0
Some((0, P4::None))
}
}
Expr::Literal(Literal::False, _) => {
if is_not {
// IS NOT FALSE: p3=1, p4=0
Some((1, P4::None))
} else {
// IS FALSE: p3=1, p4=1
Some((1, P4::Int(1)))
}
}
_ => None,
}
}
/// Emit CASE \[operand\] WHEN ... THEN ... \[ELSE ...\] END.
fn emit_case_expr(
b: &mut ProgramBuilder,
operand: Option<&Expr>,
whens: &[(Expr, Expr)],
else_expr: Option<&Expr>,
reg: i32,
ctx: Option<&ScanCtx<'_>>,
) {
let done_label = b.emit_label();
let r_operand = operand.map(|op_expr| {
let r = b.alloc_temp();
emit_expr(b, op_expr, r, ctx);
r
});
for (when_expr, then_expr) in whens {
let next_when = b.emit_label();
if let Some(r_op) = r_operand {
// Simple CASE: compare operand to each WHEN value.
let r_when = b.alloc_temp();
emit_expr(b, when_expr, r_when, ctx);
// NULL in either operand or WHEN value means no match
// (NULL = x is UNKNOWN in SQL, which is falsy for CASE).
b.emit_jump_to_label(Opcode::IsNull, r_op, 0, next_when, P4::None, 0);
b.emit_jump_to_label(Opcode::IsNull, r_when, 0, next_when, P4::None, 0);
// Simple CASE desugars to `operand = when_value`, so apply the same
// comparison affinity a plain `=` would (bd-w4r25): an INTEGER
// operand coerces a text WHEN literal to a number before comparing.
let cmp_aff =
operand.map_or(0, |op_expr| comparison_affinity_p5(op_expr, when_expr, ctx));
let comparison_collation = operand
.and_then(|op_expr| comparison_collation_ctx(op_expr, when_expr, ctx))
.map_or(P4::None, P4::Collation);
// If operand != when_value, skip to next WHEN.
b.emit_jump_to_label(
Opcode::Ne,
r_when,
r_op,
next_when,
comparison_collation,
cmp_aff,
);
b.free_temp(r_when);
} else {
// Searched CASE: each WHEN is a boolean condition in a TRUTH context.
// Stock sqlite3 short-circuits AND/OR left-to-right here (an
// sqlite3ExprIfFalse analog), so a would-be-skipped operand that
// errors is never evaluated — matching WHERE-filter behavior
// (bd-and-or-short-circuit-value-jump-gaps-dkswh GAP-2). A non-AND/OR
// condition emits byte-identically to the prior eager sequence.
emit_searched_case_when_condition(b, when_expr, reg, next_when, ctx);
}
// Emit THEN expression.
emit_expr(b, then_expr, reg, ctx);
b.emit_jump_to_label(Opcode::Goto, 0, 0, done_label, P4::None, 0);
b.resolve_label(next_when);
}
// ELSE clause (or NULL if no ELSE).
if let Some(el) = else_expr {
emit_expr(b, el, reg, ctx);
} else {
b.emit_op(Opcode::Null, 0, reg, 0, P4::None, 0);
}
b.resolve_label(done_label);
if let Some(r_op) = r_operand {
b.free_temp(r_op);
}
}
/// Emit a searched-CASE `WHEN` condition in TRUTH context: jump to `false_label`
/// when the condition is NOT TRUE (i.e. FALSE or NULL), short-circuiting AND/OR
/// left-to-right so a would-be-skipped operand is never evaluated. This mirrors
/// stock sqlite3's `sqlite3ExprIfFalse(jumpIfNull=1)` polarity used for WHERE /
/// CASE-WHEN — the same structure `emit_where_filter_with_ctx` already applies to
/// WHERE conjuncts/disjuncts (bd-and-or-short-circuit-value-jump-gaps-dkswh
/// GAP-2). `scratch` is a register the caller no longer needs after the branch
/// (the WHEN result is transient). A non-AND/OR condition emits exactly the prior
/// eager sequence — `emit_expr(cond, scratch); IfNot scratch,1 -> false_label` —
/// so bytecode is unchanged for every condition except an AND/OR-topped one.
///
/// Three-valued correctness (seeking TRUE):
/// - leaf: `IfNot p2=1` jumps iff cond is FALSE or NULL (i.e. not TRUE);
/// - `A AND B`: jump if either is not TRUE -> taken iff both TRUE;
/// - `A OR B`: if A is TRUE the whole is TRUE (skip B); else the result is B's
/// truth value -> taken iff A TRUE or B TRUE.
fn emit_searched_case_when_condition(
b: &mut ProgramBuilder,
cond: &Expr,
scratch: i32,
false_label: crate::Label,
ctx: Option<&ScanCtx<'_>>,
) {
match cond {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
emit_searched_case_when_condition(b, left, scratch, false_label, ctx);
emit_searched_case_when_condition(b, right, scratch, false_label, ctx);
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Or,
right,
..
} => {
let left_false = b.emit_label();
let pass = b.emit_label();
emit_searched_case_when_condition(b, left, scratch, left_false, ctx);
b.emit_jump_to_label(Opcode::Goto, 0, 0, pass, P4::None, 0);
b.resolve_label(left_false);
emit_searched_case_when_condition(b, right, scratch, false_label, ctx);
b.resolve_label(pass);
}
_ => {
emit_expr(b, cond, scratch, ctx);
b.emit_jump_to_label(Opcode::IfNot, scratch, 1, false_label, P4::None, 0);
}
}
}
/// Correlation-aware (inner/outer `ScanCtx`) counterpart of
/// [`emit_searched_case_when_condition`], for the searched-CASE arm of
/// `emit_expr_with_fallback`. Leaves are emitted through `emit_expr_with_fallback`
/// (each into its own temp, exactly as the prior eager code did), so outer-column
/// correlation resolution is preserved; only AND/OR-topped conditions change,
/// gaining left-to-right short-circuit.
fn emit_searched_case_when_condition_with_fallback(
b: &mut ProgramBuilder,
cond: &Expr,
false_label: crate::Label,
inner_ctx: &ScanCtx<'_>,
outer_ctx: Option<&ScanCtx<'_>>,
) {
match cond {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
emit_searched_case_when_condition_with_fallback(
b,
left,
false_label,
inner_ctx,
outer_ctx,
);
emit_searched_case_when_condition_with_fallback(
b,
right,
false_label,
inner_ctx,
outer_ctx,
);
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Or,
right,
..
} => {
let left_false = b.emit_label();
let pass = b.emit_label();
emit_searched_case_when_condition_with_fallback(
b, left, left_false, inner_ctx, outer_ctx,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, pass, P4::None, 0);
b.resolve_label(left_false);
emit_searched_case_when_condition_with_fallback(
b,
right,
false_label,
inner_ctx,
outer_ctx,
);
b.resolve_label(pass);
}
_ => {
let r_when = b.alloc_temp();
emit_expr_with_fallback(b, cond, r_when, inner_ctx, outer_ctx);
b.emit_jump_to_label(Opcode::IfNot, r_when, 1, false_label, P4::None, 0);
b.free_temp(r_when);
}
}
}
/// Upsert-context (`ON CONFLICT DO UPDATE`) counterpart of
/// [`emit_searched_case_when_condition`]. Leaves emit through `emit_upsert_expr`
/// (preserving existing/excluded pseudo-table + hidden-rowid dispatch); only
/// AND/OR-topped WHEN conditions change, gaining left-to-right short-circuit so a
/// would-be-skipped erroring operand is never evaluated. Byte-identical for every
/// non-AND/OR condition (bd-and-or-short-circuit GAP-2).
#[allow(clippy::too_many_arguments)]
fn emit_upsert_case_when_condition(
b: &mut ProgramBuilder,
cond: &Expr,
scratch: i32,
false_label: crate::Label,
existing_ctx: &ScanCtx<'_>,
excluded_ctx: &ScanCtx<'_>,
table: &TableSchema,
existing_hidden_rowid_reg: Option<i32>,
excluded_hidden_rowid_reg: i32,
) {
match cond {
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::And,
right,
..
} => {
emit_upsert_case_when_condition(
b,
left,
scratch,
false_label,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
emit_upsert_case_when_condition(
b,
right,
scratch,
false_label,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
}
Expr::BinaryOp {
left,
op: fsqlite_ast::BinaryOp::Or,
right,
..
} => {
let left_false = b.emit_label();
let pass = b.emit_label();
emit_upsert_case_when_condition(
b,
left,
scratch,
left_false,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(Opcode::Goto, 0, 0, pass, P4::None, 0);
b.resolve_label(left_false);
emit_upsert_case_when_condition(
b,
right,
scratch,
false_label,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.resolve_label(pass);
}
_ => {
emit_upsert_expr(
b,
cond,
scratch,
existing_ctx,
excluded_ctx,
table,
existing_hidden_rowid_reg,
excluded_hidden_rowid_reg,
);
b.emit_jump_to_label(Opcode::IfNot, scratch, 1, false_label, P4::None, 0);
}
}
}
fn strip_collate_wrappers(mut expr: &Expr) -> &Expr {
while let Expr::Collate { expr: inner, .. } = expr {
expr = inner;
}
expr
}
#[derive(Clone)]
struct AffinityTableBinding<'a> {
table: &'a TableSchema,
alias: Option<String>,
}
enum ScopedColumnAffinity {
Missing,
Resolved(u8),
Ambiguous,
}
fn schema_column_expr_affinity(column: &ColumnInfo) -> u8 {
if column.is_ipk {
b'D'
} else {
match column.affinity.to_ascii_uppercase() {
'B' => b'B',
'C' => b'C',
'D' => b'D',
'E' => b'E',
_ => b'A',
}
}
}
fn table_column_expr_affinity(table: &TableSchema, column_name: &str) -> Option<u8> {
if let Some(index) = table.column_index(column_name) {
return table.columns.get(index).map(schema_column_expr_affinity);
}
table.resolves_to_hidden_rowid(column_name).then_some(b'D')
}
fn column_affinity_in_scope(
column: &ColumnRef,
scope: &[AffinityTableBinding<'_>],
) -> ScopedColumnAffinity {
let mut resolved = None;
let mut qualifier_matched = false;
for binding in scope {
if let Some(qualifier) = column.table.as_deref() {
if !matches_table_or_alias(qualifier, binding.table, binding.alias.as_deref()) {
continue;
}
qualifier_matched = true;
}
let Some(affinity) = table_column_expr_affinity(binding.table, &column.column) else {
continue;
};
if resolved.replace(affinity).is_some() {
return ScopedColumnAffinity::Ambiguous;
}
}
if let Some(affinity) = resolved {
ScopedColumnAffinity::Resolved(affinity)
} else if qualifier_matched {
// A matching local qualifier shadows outer scopes even when the named
// column is invalid. Treat that shape as unprovable rather than
// accidentally borrowing affinity from an outer table.
ScopedColumnAffinity::Ambiguous
} else {
ScopedColumnAffinity::Missing
}
}
fn scoped_column_affinity(
column: &ColumnRef,
scopes: &[&[AffinityTableBinding<'_>]],
) -> Option<u8> {
for scope in scopes {
match column_affinity_in_scope(column, scope) {
ScopedColumnAffinity::Missing => {}
ScopedColumnAffinity::Resolved(affinity) => return Some(affinity),
ScopedColumnAffinity::Ambiguous => return None,
}
}
None
}
fn push_affinity_catalog_table<'a>(catalog: &mut Vec<&'a TableSchema>, table: &'a TableSchema) {
if !catalog
.iter()
.any(|candidate| std::ptr::eq(*candidate, table))
{
catalog.push(table);
}
}
fn extend_affinity_catalog_from_scan<'a>(catalog: &mut Vec<&'a TableSchema>, ctx: &ScanCtx<'a>) {
if let Some(schema) = ctx.schema {
for table in schema {
push_affinity_catalog_table(catalog, table);
}
}
push_affinity_catalog_table(catalog, ctx.table);
for secondary in ctx.secondaries {
push_affinity_catalog_table(catalog, secondary.table);
}
}
fn affinity_scope_from_scan<'a>(ctx: &ScanCtx<'a>) -> Vec<AffinityTableBinding<'a>> {
std::iter::once(AffinityTableBinding {
table: ctx.table,
alias: ctx.table_alias.map(str::to_owned),
})
.chain(
ctx.secondaries
.iter()
.map(|secondary| AffinityTableBinding {
table: secondary.table,
alias: secondary.table_alias.map(str::to_owned),
}),
)
.collect()
}
fn find_affinity_catalog_table<'a>(
name: &QualifiedName,
catalog: &[&'a TableSchema],
) -> Option<&'a TableSchema> {
// TableSchema does not retain attached-database identity, so a qualified
// source cannot be proven to name a particular catalog entry here.
if name.schema.is_some() {
return None;
}
let mut found: Option<&'a TableSchema> = None;
for table in catalog {
if !table.name.eq_ignore_ascii_case(&name.name) {
continue;
}
if let Some(previous) = found
&& !std::ptr::eq(previous, *table)
{
return None;
}
found = Some(*table);
}
found
}
fn collect_affinity_source_bindings<'a>(
source: &TableOrSubquery,
catalog: &[&'a TableSchema],
bindings: &mut Vec<AffinityTableBinding<'a>>,
) -> Option<()> {
match source {
TableOrSubquery::Table { name, alias, .. } => {
let table = find_affinity_catalog_table(name, catalog)?;
bindings.push(AffinityTableBinding {
table,
alias: alias.clone(),
});
Some(())
}
TableOrSubquery::ParenJoin(from) => collect_affinity_from_bindings(from, catalog, bindings),
// Derived tables and table-valued functions do not expose result
// affinity metadata through TableSchema. Decline rather than guessing.
TableOrSubquery::Subquery { .. } | TableOrSubquery::TableFunction { .. } => None,
}
}
fn collect_affinity_from_bindings<'a>(
from: &FromClause,
catalog: &[&'a TableSchema],
bindings: &mut Vec<AffinityTableBinding<'a>>,
) -> Option<()> {
collect_affinity_source_bindings(&from.source, catalog, bindings)?;
for join in &from.joins {
collect_affinity_source_bindings(&join.table, catalog, bindings)?;
}
Some(())
}
fn first_star_affinity(scope: &[AffinityTableBinding<'_>]) -> Option<u8> {
scope
.iter()
.find_map(|binding| binding.table.columns.first())
.map(schema_column_expr_affinity)
}
fn first_table_star_affinity(
name: &QualifiedName,
scope: &[AffinityTableBinding<'_>],
) -> Option<u8> {
if name.schema.is_some() {
return None;
}
let mut found = None;
for binding in scope {
if !matches_table_or_alias(&name.name, binding.table, binding.alias.as_deref()) {
continue;
}
let affinity = binding
.table
.columns
.first()
.map(schema_column_expr_affinity)?;
if found.replace(affinity).is_some() {
return None;
}
}
found
}
fn scoped_expr_affinity(
expr: &Expr,
scopes: &[&[AffinityTableBinding<'_>]],
catalog: &[&TableSchema],
) -> Option<u8> {
match strip_collate_wrappers(expr) {
Expr::BoundOuterValue { affinity, .. } => Some(bound_outer_affinity_code(*affinity)),
Expr::Column(column, _) => scoped_column_affinity(column, scopes),
Expr::Cast { type_name, .. } => Some(type_name_to_affinity(type_name)),
Expr::Subquery(select, _) => scalar_subquery_result_affinity(select, scopes, catalog),
// Literals and every other computed expression have NONE affinity.
_ => Some(b'A'),
}
}
fn intrinsic_expr_affinity(expr: &Expr) -> Option<u8> {
match strip_collate_wrappers(expr) {
Expr::BoundOuterValue { affinity, .. } => Some(bound_outer_affinity_code(*affinity)),
Expr::Column(..) | Expr::Subquery(..) => None,
Expr::Cast { type_name, .. } => Some(type_name_to_affinity(type_name)),
// Every other expression shape is computed and therefore has NONE
// affinity even when it contains column references.
_ => Some(b'A'),
}
}
fn select_core_first_result_affinity(
core: &SelectCore,
outer_scopes: &[&[AffinityTableBinding<'_>]],
catalog: &[&TableSchema],
) -> Option<u8> {
match core {
SelectCore::Select { columns, from, .. } => {
let local_scope = from.as_ref().and_then(|from| {
let mut bindings = Vec::new();
collect_affinity_from_bindings(from, catalog, &mut bindings)?;
Some(bindings)
});
let result = columns.first()?;
if from.is_some() && local_scope.is_none() {
return match result {
ResultColumn::Expr { expr, .. } => intrinsic_expr_affinity(expr),
ResultColumn::Star | ResultColumn::TableStar(_) => None,
};
}
let mut scopes = Vec::with_capacity(outer_scopes.len() + usize::from(from.is_some()));
if let Some(local_scope) = local_scope.as_deref() {
scopes.push(local_scope);
}
scopes.extend_from_slice(outer_scopes);
match result {
ResultColumn::Expr { expr, .. } => scoped_expr_affinity(expr, &scopes, catalog),
ResultColumn::Star => local_scope.as_deref().and_then(first_star_affinity),
ResultColumn::TableStar(name) => local_scope
.as_deref()
.and_then(|scope| first_table_star_affinity(name, scope)),
}
}
SelectCore::Values(values) => {
// VALUES comparison metadata follows the representation selected
// before rewrites. A deferred clause has not consulted the active
// function registry yet, so declining affinity is the only safe
// answer here; guessing from a syntactic row can change comparison
// coercion and therefore query results.
let expr = values.donor_row()?.first()?;
scoped_expr_affinity(expr, outer_scopes, catalog)
}
}
}
/// Determine the affinity of a scalar subquery's first result column.
///
/// SQLite preserves this affinity across the scalar-subquery boundary, while
/// result collation remains local to the SELECT. Direct scalar codegen takes
/// affinity from the parser-root SELECT, which is the rightmost compound arm.
/// A VALUES core instead uses its frozen representation donor and declines to
/// provide affinity while donor selection is still deferred.
fn scalar_subquery_result_affinity(
select: &SelectStatement,
outer_scopes: &[&[AffinityTableBinding<'_>]],
catalog: &[&TableSchema],
) -> Option<u8> {
let parser_root = select
.body
.compounds
.last()
.map_or(&select.body.select, |(_, core)| core);
select_core_first_result_affinity(parser_root, outer_scopes, catalog)
}
fn scalar_subquery_affinity_in_scan(
select: &SelectStatement,
ctx: Option<&ScanCtx<'_>>,
) -> Option<u8> {
let mut catalog = Vec::new();
let scope = ctx.map(affinity_scope_from_scan);
if let Some(ctx) = ctx {
extend_affinity_catalog_from_scan(&mut catalog, ctx);
}
let outer_scopes = scope.as_deref().map_or_else(Vec::new, |scope| vec![scope]);
scalar_subquery_result_affinity(select, &outer_scopes, &catalog)
}
fn scalar_subquery_affinity_with_fallback(
select: &SelectStatement,
inner: &ScanCtx<'_>,
outer: Option<&ScanCtx<'_>>,
) -> Option<u8> {
let inner_scope = affinity_scope_from_scan(inner);
let outer_scope = outer.map(affinity_scope_from_scan);
let mut scopes = vec![inner_scope.as_slice()];
if let Some(outer_scope) = outer_scope.as_deref() {
scopes.push(outer_scope);
}
let mut catalog = Vec::new();
extend_affinity_catalog_from_scan(&mut catalog, inner);
if let Some(outer) = outer {
extend_affinity_catalog_from_scan(&mut catalog, outer);
}
scalar_subquery_result_affinity(select, &scopes, &catalog)
}
fn scalar_subquery_affinity_in_join(
select: &SelectStatement,
tables: &[(&TableSchema, Option<&str>)],
) -> Option<u8> {
let scope = tables
.iter()
.map(|(table, alias)| AffinityTableBinding {
table,
alias: alias.map(str::to_owned),
})
.collect::<Vec<_>>();
let mut catalog = Vec::new();
for (table, _) in tables {
push_affinity_catalog_table(&mut catalog, table);
}
scalar_subquery_result_affinity(select, &[scope.as_slice()], &catalog)
}
/// Determine the type affinity of an expression for comparison coercion.
///
/// Per SQLite §3.2 (comparisonAffinity), column references and CAST expressions
/// have affinity for comparison purposes, and a scalar subquery preserves the
/// affinity of its first projected result. Literals and other computed
/// expressions have BLOB/NONE affinity (i.e., no coercion influence).
///
/// Returns SQLite affinity codes: A=BLOB, B=TEXT, C=NUMERIC, D=INTEGER, E=REAL.
fn expr_affinity(expr: &Expr, ctx: Option<&ScanCtx<'_>>) -> u8 {
// COLLATE changes comparison ordering but preserves the underlying
// expression affinity, including through repeated postfix wrappers.
// Do not unwrap unary plus: SQLite intentionally strips affinity there.
let inner = strip_collate_wrappers(expr);
match inner {
Expr::BoundOuterValue { affinity, .. } => bound_outer_affinity_code(*affinity),
Expr::Column(col_ref, _) => {
// Look up column type in the table schema
if let Some(ctx) = ctx {
// Check primary table
let check_table = |table: &TableSchema, alias: Option<&str>| -> Option<u8> {
if let Some(qualifier) = &col_ref.table
&& !matches_table_or_alias(qualifier, table, alias)
{
return None;
}
if let Some(idx) = table.column_index(&col_ref.column) {
return Some(schema_column_expr_affinity(&table.columns[idx]));
}
table
.resolves_to_hidden_rowid(&col_ref.column)
.then_some(b'D')
};
if let Some(aff) = check_table(ctx.table, ctx.table_alias) {
return aff;
}
// Check secondary tables (UPDATE ... FROM, possibly multi-source)
for sec in ctx.secondaries {
if let Some(aff) = check_table(sec.table, sec.table_alias) {
return aff;
}
}
// Check full schema
if let Some(schema) = ctx.schema {
for table in schema {
if let Some(qualifier) = &col_ref.table
&& !table.name.eq_ignore_ascii_case(qualifier)
{
continue;
}
if let Some(idx) = table.column_index(&col_ref.column) {
return schema_column_expr_affinity(&table.columns[idx]);
}
if table.resolves_to_hidden_rowid(&col_ref.column) {
return b'D';
}
}
}
}
b'A' // BLOB/NONE affinity if not found
}
// Literals have NO column affinity for comparison purposes.
// Per C SQLite: only TK_COLUMN and TK_CAST produce comparison affinity.
Expr::Cast { type_name, .. } => type_name_to_affinity(type_name),
Expr::Subquery(select, _) => scalar_subquery_affinity_in_scan(select, ctx).unwrap_or(b'A'),
_ => b'A', // BLOB/NONE affinity for literals and computed expressions
}
}
/// Compute the comparison affinity P5 value for a binary comparison.
/// Implements SQLite's Section 4.2 type conversion rules.
fn comparison_affinity_p5(left: &Expr, right: &Expr, ctx: Option<&ScanCtx<'_>>) -> u16 {
combine_comparison_affinity(expr_affinity(left, ctx), expr_affinity(right, ctx))
}
/// Affinity that SQLite applies to every element of a value-list / subquery
/// `IN` comparison: the affinity of the left-hand operand applied uniformly
/// (datatype3 §4.2 — IN uses `sqlite3ExprAffinity(LHS)` against the RHS set).
/// Returns a `p5` affinity code (`0`, `b'B'`, or `b'C'`).
fn in_operand_affinity_p5(operand: &Expr, ctx: Option<&ScanCtx<'_>>) -> u16 {
// The RHS set elements contribute NONE/BLOB affinity, so the result is
// governed entirely by the operand's own affinity.
combine_comparison_affinity(expr_affinity(operand, ctx), b'A')
}
/// Resolve an expression's comparison affinity in the correlated-subquery
/// fallback path: prefer the inner (subquery) context, but fall back to the
/// outer context when the column does not resolve in the inner scope.
fn fallback_expr_affinity(expr: &Expr, inner: &ScanCtx<'_>, outer: Option<&ScanCtx<'_>>) -> u8 {
match strip_collate_wrappers(expr) {
Expr::Column(col_ref, _) => {
if column_ref_resolves_in_ctx(col_ref, inner) {
return expr_affinity(expr, Some(inner));
}
outer
.filter(|outer| column_ref_resolves_in_ctx(col_ref, outer))
.map_or(b'A', |outer| expr_affinity(expr, Some(outer)))
}
Expr::Subquery(select, _) => {
scalar_subquery_affinity_with_fallback(select, inner, outer).unwrap_or(b'A')
}
// CAST has intrinsic affinity; every other computed expression has NONE.
_ => expr_affinity(expr, Some(inner)),
}
}
/// Convert a SQL type name to an affinity character code.
fn type_name_to_affinity(type_name: &fsqlite_ast::TypeName) -> u8 {
// Encoding: A..E maps to BLOB, TEXT, NUMERIC, INTEGER, REAL:
// 'A' = BLOB, 'B' = TEXT, 'C' = NUMERIC, 'D' = INTEGER, 'E' = REAL.
let name = type_name.name.to_uppercase();
if name.contains("INT") {
b'D' // INTEGER affinity
} else if name.contains("CHAR") || name.contains("TEXT") || name.contains("CLOB") {
b'B' // TEXT affinity
} else if name.contains("BLOB") {
b'A' // BLOB affinity
} else if name.contains("REAL") || name.contains("FLOA") || name.contains("DOUB") {
b'E' // REAL affinity
} else {
// NUMERIC affinity — also the SQLite default for an EMPTY CAST type
// name (`CAST(x AS)`), which is `sqlite3AffinityType`'s fallback:
// it behaves like `CAST(x AS NUMERIC)`. bd-errmsg-parity-batch4-deqcb.
b'C'
}
}
/// Convert days since 1970-01-01 (Unix epoch) to (year, month, day).
fn epoch_days_to_ymd(days: u64) -> (u64, u64, u64) {
// Civil-date algorithm from Howard Hinnant (public domain).
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
// ---------------------------------------------------------------------------
// Public helpers for cross-crate partial-index support
// ---------------------------------------------------------------------------
/// Emit VDBE bytecode that evaluates `where_expr` against the current row of
/// `cursor` (which scans `table`) and jumps to `skip_label` when the predicate
/// is **not** satisfied.
///
/// Used by `backfill_index` in `fsqlite-core` to skip rows that don't match a
/// partial index WHERE clause.
pub fn emit_scan_filter(
b: &mut ProgramBuilder,
where_expr: &Expr,
cursor: i32,
table: &TableSchema,
skip_label: Label,
) {
let scan = ScanCtx {
cursor,
table,
table_alias: None,
schema: None,
register_base: None,
secondaries: &[],
};
let filter_reg = b.alloc_temp();
emit_expr(b, where_expr, filter_reg, Some(&scan));
b.emit_jump_to_label(Opcode::IfNot, filter_reg, 1, skip_label, P4::None, 0);
b.free_temp(filter_reg);
}
/// Emit opcodes that evaluate `expr` and store the result in `target_reg`,
/// reading column values from `cursor` which is opened on `table`.
/// Used by index backfill to evaluate expression-index key terms.
pub fn emit_backfill_key_expr(
b: &mut ProgramBuilder,
expr: &Expr,
target_reg: i32,
cursor: i32,
table: &TableSchema,
) {
let scan = ScanCtx {
cursor,
table,
table_alias: None,
schema: None,
register_base: None,
secondaries: &[],
};
emit_expr(b, expr, target_reg, Some(&scan));
}
/// Emit the read of a plain-column index key term during index backfill.
///
/// Delegates to `emit_table_column_read` so a plain (non-expression) index on a
/// VIRTUAL generated column (bd-gh-virtual-generated-columns-5e0u1 / GH#227)
/// computes the generating expression on read instead of reading the NULL
/// placeholder record slot. Without this, pre-existing rows backfilled at
/// `CREATE INDEX` time would get NULL index keys, silently diverging from keys
/// written by post-creation DML. INTEGER PRIMARY KEY columns read the rowid;
/// all other columns read the record slot directly, so this is a drop-in
/// replacement for a raw `Opcode::Column` read.
pub fn emit_backfill_column_read(
b: &mut ProgramBuilder,
col_idx: usize,
target_reg: i32,
cursor: i32,
table: &TableSchema,
) {
emit_table_column_read(b, cursor, table, None, None, col_idx, target_reg);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_failure() -> bool {
false
}
use crate::ProgramBuilder;
use crate::engine::{ExecOutcome, MemDatabase, VdbeEngine};
use asupersync::runtime::RuntimeBuilder;
use fsqlite_ast::{
Assignment, AssignmentTarget, BinaryOp as AstBinaryOp, BoundCollation, ColumnRef,
DeleteStatement, Distinctness, Expr, FromClause, InSet, InsertSource, InsertStatement,
JoinClause, JoinConstraint, JoinKind, JoinType, LimitClause, Literal, OrderingTerm,
PlaceholderType, QualifiedName, QualifiedTableRef, ResultColumn, SelectBody, SelectCore,
SelectStatement, SortDirection, Span, Statement, TableOrSubquery, UpdateStatement,
};
use fsqlite_func::{FunctionRegistry, register_builtins};
use fsqlite_parser::parse_first_statement_with_tail;
use fsqlite_types::opcode::{Opcode, P4, VdbeOp};
use proptest::prelude::*;
use proptest::{prop_oneof, proptest};
use std::panic::{AssertUnwindSafe, catch_unwind};
fn test_schema() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
#[test]
fn fallback_json_operator_resolves_inner_and_outer_operands_positionally() {
let schema = test_schema_with_subquery_source();
let outer = ScanCtx {
cursor: 3,
table: &schema[0],
table_alias: Some("o"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let inner = ScanCtx {
cursor: 4,
table: &schema[1],
table_alias: Some("i"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
for (left, right, expected_left_cursor, expected_right_cursor) in [
(("o", "a"), ("i", "b"), 3, 4),
(("i", "b"), ("o", "a"), 4, 3),
] {
let expr = Expr::JsonAccess {
expr: Box::new(Expr::Column(
ColumnRef::qualified(left.0, left.1),
Span::ZERO,
)),
path: Box::new(Expr::Column(
ColumnRef::qualified(right.0, right.1),
Span::ZERO,
)),
arrow: JsonArrow::Arrow,
span: Span::ZERO,
};
let mut builder = ProgramBuilder::new();
let result = builder.alloc_reg();
emit_expr_with_fallback(&mut builder, &expr, result, &inner, Some(&outer));
// A finished program must terminate with Halt (V2.3 invariant); the
// expression-fragment emit above does not, so append it exactly as
// the statement codegen paths do before `finish()`.
builder.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let program = builder
.finish()
.expect("JSON fallback program should finish");
let function = program
.ops()
.iter()
.find(|op| op.opcode == Opcode::PureFunc)
.expect("JSON fallback must emit a scalar function call");
assert_eq!(function.p4, P4::FuncName("->".to_owned()));
assert_eq!(function.p5, 2);
let arg_base = function.p2;
assert!(program.ops().iter().any(|op| {
op.opcode == Opcode::Column && op.p1 == expected_left_cursor && op.p3 == arg_base
}));
assert!(program.ops().iter().any(|op| {
op.opcode == Opcode::Column
&& op.p1 == expected_right_cursor
&& op.p3 == arg_base + 1
}));
}
}
#[test]
fn fallback_json_operator_resolves_secondary_outer_scan_columns() {
let schema = test_schema_with_subquery_source();
let secondary_table = TableSchema {
name: "u".to_owned(),
root_page: 4,
columns: vec![ColumnInfo::basic("b", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
};
let secondaries = [SecondaryScan {
cursor: 7,
table: &secondary_table,
table_alias: Some("u"),
register_base: None,
}];
let outer = ScanCtx {
cursor: 3,
table: &schema[0],
table_alias: Some("o"),
schema: Some(&schema),
register_base: None,
secondaries: &secondaries,
};
let inner = ScanCtx {
cursor: 4,
table: &schema[1],
table_alias: Some("i"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let expr = Expr::JsonAccess {
expr: Box::new(Expr::Column(ColumnRef::qualified("u", "b"), Span::ZERO)),
path: Box::new(Expr::Column(ColumnRef::qualified("i", "b"), Span::ZERO)),
arrow: JsonArrow::DoubleArrow,
span: Span::ZERO,
};
let mut builder = ProgramBuilder::new();
let result = builder.alloc_reg();
emit_expr_with_fallback(&mut builder, &expr, result, &inner, Some(&outer));
// A finished program must terminate with Halt (V2.3 invariant); append
// it exactly as the statement codegen paths do before `finish()`.
builder.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let program = builder
.finish()
.expect("JSON fallback program should finish");
let function = program
.ops()
.iter()
.find(|op| op.opcode == Opcode::PureFunc)
.expect("JSON fallback must emit a scalar function call");
assert_eq!(function.p4, P4::FuncName("->>".to_owned()));
assert!(
program
.ops()
.iter()
.any(|op| { op.opcode == Opcode::Column && op.p1 == 7 && op.p3 == function.p2 })
);
assert!(
!program
.ops()
.iter()
.any(|op| { op.opcode == Opcode::Null && op.p2 == function.p2 }),
"a secondary outer reference must never degrade to SQL NULL",
);
}
fn create_index_sql(sql: &str) -> CreateIndexStatement {
let Some((statement, tail)) =
parse_first_statement_with_tail(sql).expect("test CREATE INDEX SQL should parse")
else {
unreachable!("expected parsed CREATE INDEX statement");
};
assert_eq!(
tail,
sql.len(),
"parser should consume the whole SQL string"
);
match statement {
Statement::CreateIndex(stmt) => stmt,
other => unreachable!("expected CREATE INDEX statement, got {other:?}"),
}
}
#[test]
fn bind_explicit_index_binds_rootless_simple_index_metadata() {
let mut schema = test_schema();
schema[0].columns[0].collation = Some("NOCASE".to_owned());
let stmt = create_index_sql("CREATE UNIQUE INDEX MiXeD ON T(a DESC)");
let bound =
bind_explicit_index(&stmt, "mixed", "t", &schema[0]).expect("simple index should bind");
assert_eq!(bound.name, "MiXeD");
assert_eq!(bound.columns, ["a"]);
assert!(bound.key_expressions.is_empty());
assert_eq!(bound.key_sort_directions, [SortDirection::Desc]);
assert_eq!(bound.key_collations, [Some("NOCASE".to_owned())]);
assert!(bound.where_clause.is_none());
assert!(bound.is_unique);
let index = bound.into_index_schema(41);
assert_eq!(index.root_page, 41);
assert_eq!(index.name, "MiXeD");
assert_eq!(index.conflict_action, None);
}
#[test]
fn bind_explicit_index_binds_expression_terms_and_preserves_custom_collation() {
let schema = test_schema();
let stmt = create_index_sql(
"CREATE INDEX idx_expr ON t(lower(a) COLLATE custom_sort DESC, b + 1 ASC)",
);
let bound = bind_explicit_index(&stmt, "idx_expr", "t", &schema[0])
.expect("expression index should bind before registry validation");
assert!(bound.columns.is_empty());
assert_eq!(bound.key_expressions.len(), 2);
assert_eq!(bound.key_expressions[0], "lower(a) COLLATE custom_sort");
assert_eq!(bound.key_expressions[1], "b + 1");
assert_eq!(
bound.key_sort_directions,
[SortDirection::Desc, SortDirection::Asc]
);
assert_eq!(bound.key_collations, [Some("custom_sort".to_owned()), None]);
}
#[test]
fn bind_explicit_index_keeps_nested_and_derived_collations_out_of_key_metadata() {
let mut schema = test_schema();
schema[0].columns[0].collation = Some("declared_sort".to_owned());
for expression in [
"lower(a COLLATE nested_sort)",
"nullif(a COLLATE nested_sort, 'z')",
"+(a COLLATE nested_sort)",
"CAST(a COLLATE nested_sort AS TEXT)",
"+a",
"CAST(a AS TEXT)",
"0 AND (a COLLATE nested_sort = 'x')",
"(a COLLATE nested_sort = 'x') AND 0",
] {
let sql = format!("CREATE INDEX idx_expr ON t({expression})");
let stmt = create_index_sql(&sql);
let bound = bind_explicit_index(&stmt, "idx_expr", "t", &schema[0])
.expect("derived expression index should bind");
assert_eq!(
bound.key_collations,
[None],
"{expression} must keep BINARY key ordering"
);
}
for (expression, expected) in [
("a", "declared_sort"),
("a COLLATE root_sort", "root_sort"),
("lower(a) COLLATE root_sort", "root_sort"),
] {
let sql = format!("CREATE INDEX idx_expr ON t({expression})");
let stmt = create_index_sql(&sql);
let bound = bind_explicit_index(&stmt, "idx_expr", "t", &schema[0])
.expect("root-collated expression index should bind");
assert_eq!(
bound.key_collations,
[Some(expected.to_owned())],
"{expression} must retain its key collation"
);
}
}
#[test]
fn bind_explicit_index_binds_partial_predicate() {
let schema = test_schema();
let stmt = create_index_sql("CREATE INDEX idx_partial ON t(a) WHERE t.b > 0 AND rowid > 0");
let bound = bind_explicit_index(&stmt, "idx_partial", "t", &schema[0])
.expect("partial index should bind");
assert_eq!(bound.columns, ["a"]);
assert_eq!(bound.where_clause.as_deref(), Some("t.b > 0 AND rowid > 0"));
}
#[test]
fn bind_explicit_index_rejects_catalog_identity_and_unknown_table() {
let schema = test_schema();
let stmt = create_index_sql("CREATE INDEX declared_name ON t(a)");
let error = bind_explicit_index(&stmt, "catalog_name", "t", &schema[0])
.expect_err("catalog index-name mismatch must fail");
assert!(
matches!(error, CodegenError::Unsupported(message) if message.contains("identity mismatch"))
);
let stmt = create_index_sql("CREATE INDEX idx_other ON other(a)");
let error = bind_explicit_index(&stmt, "idx_other", "other", &schema[0])
.expect_err("supplied schema must match the target table");
assert_eq!(error, CodegenError::TableNotFound("other".to_owned()));
}
#[test]
fn bind_explicit_index_rejects_unknown_simple_and_expression_columns() {
let schema = test_schema();
for sql in [
"CREATE INDEX idx_missing ON t(missing)",
"CREATE INDEX idx_missing ON t(lower(missing))",
] {
let stmt = create_index_sql(sql);
let error = bind_explicit_index(&stmt, "idx_missing", "t", &schema[0])
.expect_err("unknown indexed column must fail");
assert!(matches!(error, CodegenError::ColumnNotFound { .. }));
}
}
#[test]
fn bind_explicit_index_rejects_qualified_and_hidden_rowid_key_references() {
let schema = test_schema();
for sql in [
"CREATE INDEX idx_invalid ON t(t.a)",
"CREATE INDEX idx_invalid ON t(lower(t.a))",
] {
let stmt = create_index_sql(sql);
let error = bind_explicit_index(&stmt, "idx_invalid", "t", &schema[0])
.expect_err("qualified index key reference must fail");
assert!(
matches!(error, CodegenError::Unsupported(message) if message.contains("operator"))
);
}
let stmt = create_index_sql("CREATE INDEX idx_invalid ON t(rowid + 1)");
let error = bind_explicit_index(&stmt, "idx_invalid", "t", &schema[0])
.expect_err("hidden rowid aliases are not indexable columns");
assert_eq!(
error,
CodegenError::ColumnNotFound {
table: "t".to_owned(),
column: "rowid".to_owned(),
}
);
}
#[test]
fn bind_explicit_index_rejects_unknown_partial_predicate_column() {
let schema = test_schema();
let stmt = create_index_sql("CREATE INDEX idx_partial_missing ON t(a) WHERE missing > 0");
let error = bind_explicit_index(&stmt, "idx_partial_missing", "t", &schema[0])
.expect_err("unknown partial-index predicate column must fail");
assert_eq!(
error,
CodegenError::ColumnNotFound {
table: "t".to_owned(),
column: "missing".to_owned(),
}
);
}
#[test]
fn bind_explicit_index_rejects_malformed_terms_and_collation_shape() {
let schema = test_schema();
let mut empty = create_index_sql("CREATE INDEX idx_empty ON t(a)");
empty.columns.clear();
let error = bind_explicit_index(&empty, "idx_empty", "t", &schema[0])
.expect_err("empty key-term list must fail");
assert!(
matches!(error, CodegenError::Unsupported(message) if message.contains("at least one key term"))
);
let mut placeholder_term = create_index_sql("CREATE INDEX idx_param ON t(a)");
placeholder_term.columns[0].expr = placeholder(1);
let error = bind_explicit_index(&placeholder_term, "idx_param", "t", &schema[0])
.expect_err("bind parameter key term must fail");
assert!(
matches!(error, CodegenError::Unsupported(message) if message.contains("bind parameters"))
);
let mut empty_collation = create_index_sql("CREATE INDEX idx_coll ON t(a)");
empty_collation.columns[0].collation = Some(String::new());
let error = bind_explicit_index(&empty_collation, "idx_coll", "t", &schema[0])
.expect_err("empty collation name must fail");
assert!(
matches!(error, CodegenError::Unsupported(message) if message.contains("empty collation"))
);
}
fn test_schema_with_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![IndexSchema {
name: "idx_t_b".to_owned(),
root_page: 3,
columns: vec!["b".to_owned()],
key_expressions: vec!["b".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
/// Like [`test_schema_with_index`] but column `b` has TEXT affinity (`'B'`)
/// with the default BINARY collation. A case-stable LIKE/GLOB prefix
/// (`'123%'`) only lowers to an index range seek when the derived TEXT
/// bounds compare byte-exactly against the index — i.e. a TEXT-affinity,
/// BINARY-collated column. On the NUMERIC-affinity `test_schema_with_index`
/// the text bounds would be coerced to numbers and the range seek would
/// silently skip lexical matches, so `index_range_fast_path_is_safe`
/// correctly declines it there (matching stock sqlite3, which SCANs).
fn test_schema_with_text_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'B', false),
],
indexes: vec![IndexSchema {
name: "idx_t_b".to_owned(),
root_page: 3,
columns: vec!["b".to_owned()],
key_expressions: vec!["b".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_expression_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("name", 'B', false),
],
indexes: vec![IndexSchema {
name: "idx_t_lower_name".to_owned(),
root_page: 3,
columns: Vec::new(),
key_expressions: vec!["lower(name)".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_small_bench_schema() -> Vec<TableSchema> {
vec![TableSchema {
name: "bench".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("name", 'B', false),
ColumnInfo::basic("value", 'E', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn seed_small_bench_db(row_count: usize) -> MemDatabase {
let mut db = MemDatabase::new();
db.create_table_at(2, 3);
let table = db.get_table_mut(2).expect("bench table should exist");
for id in 0..row_count {
let id = i64::try_from(id).expect("row ids should fit in i64");
table.insert_row(
id,
vec![
SqliteValue::Integer(id),
SqliteValue::Text(format!("name{id}").into()),
SqliteValue::Float(((id * 3) + 1) as f64),
],
);
}
db
}
fn execute_codegen_select_with_storage_cursor(
stmt: &SelectStatement,
schema: &[TableSchema],
db: MemDatabase,
) -> Vec<Vec<SqliteValue>> {
let mut registry = FunctionRegistry::new();
register_builtins(&mut registry);
execute_codegen_select_with_registry(stmt, schema, db, registry)
}
fn execute_codegen_select_with_registry(
stmt: &SelectStatement,
schema: &[TableSchema],
db: MemDatabase,
registry: FunctionRegistry,
) -> Vec<Vec<SqliteValue>> {
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, stmt, schema, &ctx).expect("select should codegen");
let prog = b.finish().expect("program should build");
let mut engine = VdbeEngine::new(prog.register_count());
engine.enable_storage_read_cursors(true);
engine.set_database(db);
engine.set_reject_mem_fallback(false);
engine.set_function_registry(std::sync::Arc::new(registry));
let runtime = RuntimeBuilder::current_thread()
.blocking_threads(1, 2)
.build()
.expect("build codegen storage-cursor test runtime");
let outcome = runtime
.block_on(async { engine.execute(&prog).await })
.expect("execution should succeed");
assert_eq!(outcome, ExecOutcome::Done);
engine
.take_results()
.into_iter()
.map(|row| row.into_vec())
.collect()
}
fn test_schema_with_nocase_text_column() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo {
name: "name".to_owned(),
affinity: 'B',
is_ipk: false,
type_name: Some("TEXT".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: Some("NOCASE".to_owned()),
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_nocase_text_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo {
name: "name".to_owned(),
affinity: 'B',
is_ipk: false,
type_name: Some("TEXT".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: Some("NOCASE".to_owned()),
conflict_action: None,
}],
indexes: vec![IndexSchema {
name: "idx_t_name".to_owned(),
root_page: 3,
columns: vec!["name".to_owned()],
key_expressions: vec!["name".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![Some("NOCASE".to_owned())],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_typed_numeric_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo {
name: "n".to_owned(),
affinity: 'D',
is_ipk: false,
type_name: Some("INTEGER".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
}],
indexes: vec![IndexSchema {
name: "idx_t_n".to_owned(),
root_page: 3,
columns: vec!["n".to_owned()],
key_expressions: vec!["n".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_subquery_source() -> Vec<TableSchema> {
vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo {
name: "a".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "b".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![ColumnInfo::basic("b", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn scalar_affinity_test_schema() -> Vec<TableSchema> {
let typed_column = |name: &str, affinity: char, type_name: &str| {
let mut column = ColumnInfo::basic(name, affinity, false);
column.type_name = Some(type_name.to_owned());
column
};
let mut schema = test_schema_with_subquery_source();
schema[0].columns = vec![
typed_column("outer_n", 'C', "NUMERIC"),
typed_column("outer_t", 'B', "TEXT"),
typed_column("outer_b", 'A', "BLOB"),
];
schema[1].columns = vec![
typed_column("n", 'C', "NUMERIC"),
typed_column("txt", 'B', "TEXT"),
typed_column("raw", 'A', "BLOB"),
];
schema[1].columns[1].collation = Some("NOCASE".to_owned());
schema
}
fn test_schema_with_index_and_subquery_source() -> Vec<TableSchema> {
vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'd', false),
],
indexes: vec![IndexSchema {
name: "idx_t_b".to_owned(),
root_page: 4,
columns: vec!["b".to_owned()],
key_expressions: vec!["b".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![ColumnInfo::basic("b", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn test_schema_with_join_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "customers".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("name", 'B', false),
ColumnInfo::basic("region", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "orders".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("customer_id", 'D', false),
ColumnInfo::basic("amount", 'E', false),
ColumnInfo::basic("status", 'B', false),
],
indexes: vec![IndexSchema {
name: "idx_orders_customer_id".to_owned(),
root_page: 4,
columns: vec!["customer_id".to_owned()],
key_expressions: vec!["customer_id".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn grouped_join_count_sum_index_lookup_stmt() -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::qualified("o", "amount"),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("o", "customer_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("c", "id"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO)],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn grouped_join_count_sum_rowid_lookup_stmt() -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::qualified("o", "amount"),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("o", "customer_id"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO)],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn from_table(name: &str) -> FromClause {
FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare(name),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}
}
fn from_table_as(name: &str, alias: &str) -> FromClause {
FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare(name),
alias: Some(alias.to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}
}
fn placeholder(n: u32) -> Expr {
Expr::Placeholder(PlaceholderType::Numbered(n), Span::ZERO)
}
fn anonymous_placeholder() -> Expr {
Expr::Placeholder(PlaceholderType::Anonymous, Span::ZERO)
}
fn rowid_eq_param() -> Box<Expr> {
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
})
}
fn col_cmp_param(col: &str, op: AstBinaryOp, n: u32) -> Box<Expr> {
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare(col), Span::ZERO)),
op,
right: Box::new(placeholder(n)),
span: Span::ZERO,
})
}
fn qualified_col_cmp_param(table: &str, col: &str, op: AstBinaryOp, n: u32) -> Box<Expr> {
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified(table, col), Span::ZERO)),
op,
right: Box::new(placeholder(n)),
span: Span::ZERO,
})
}
fn and_expr(left: Box<Expr>, right: Box<Expr>) -> Box<Expr> {
Box::new(Expr::BinaryOp {
left,
op: AstBinaryOp::And,
right,
span: Span::ZERO,
})
}
fn expr_sql(sql: &str) -> Expr {
parse_sql_expr(sql).expect("test expression SQL should parse")
}
#[test]
fn singleton_in_rhs_uses_sealed_query_constancy_metadata() {
let mut registry = FunctionRegistry::new();
register_builtins(&mut registry);
with_connection_function_registry_context(
Some(Arc::new(registry)),
Vec::new(),
true,
true,
|| {
assert!(singleton_in_rhs_is_constant(&expr_sql("CURRENT_TIMESTAMP")));
assert!(singleton_in_rhs_is_constant(&expr_sql("sqlite_version()")));
assert!(singleton_in_rhs_is_constant(&expr_sql(
"date('2000-01-01')"
)));
assert!(singleton_in_rhs_is_constant(&expr_sql("abs(1)")));
assert!(!singleton_in_rhs_is_constant(&expr_sql("random()")));
assert!(!singleton_in_rhs_is_constant(&expr_sql("abs(random())")));
let mut distinct = expr_sql("abs(1)");
let Expr::FunctionCall {
distinct: is_distinct,
..
} = &mut distinct
else {
unreachable!("abs(1) should parse as a function call");
};
*is_distinct = true;
assert!(!singleton_in_rhs_is_constant(&distinct));
let mut star = expr_sql("abs(1)");
let Expr::FunctionCall { args, .. } = &mut star else {
unreachable!("abs(1) should parse as a function call");
};
*args = FunctionArgs::Star;
assert!(!singleton_in_rhs_is_constant(&star));
assert!(!singleton_in_rhs_is_constant(&Expr::RowValue(
Vec::new(),
Span::ZERO,
)));
},
);
with_connection_function_registry_context(None, Vec::new(), true, true, || {
assert!(
!singleton_in_rhs_is_constant(&expr_sql("abs(1)")),
"missing frozen registry metadata must fail closed"
);
});
}
fn scalar_values_with_frozen_donor(sql: &str, donor_row: usize) -> Expr {
let mut expr = expr_sql(sql);
let Expr::Subquery(select, _) = &mut expr else {
unreachable!("expected scalar VALUES subquery expression");
};
assert!(
select.body.compounds.is_empty(),
"VALUES donor fixture must have one parser-root core"
);
let SelectCore::Values(values) = &mut select.body.select else {
unreachable!("expected VALUES parser-root core");
};
values.freeze_donor_row(Some(donor_row));
expr
}
fn select_sql(sql: &str) -> SelectStatement {
let Some((statement, tail)) =
parse_first_statement_with_tail(sql).expect("test SELECT SQL should parse")
else {
unreachable!("expected parsed SELECT statement");
};
assert_eq!(
tail,
sql.len(),
"parser should consume the whole SQL string"
);
match statement {
Statement::Select(stmt) => stmt,
other => unreachable!("expected SELECT statement, got {other:?}"),
}
}
fn fuzz_literal() -> impl Strategy<Value = String> {
prop_oneof![
any::<i16>().prop_map(|n| {
if n.is_negative() {
format!("({n})")
} else {
n.to_string()
}
}),
Just("NULL".to_owned()),
Just("TRUE".to_owned()),
Just("FALSE".to_owned()),
]
}
fn fuzz_column() -> impl Strategy<Value = &'static str> {
prop_oneof![Just("a"), Just("b")]
}
fn fuzz_expr(depth: u32) -> BoxedStrategy<String> {
if depth == 0 {
prop_oneof![fuzz_literal(), fuzz_column().prop_map(str::to_owned),].boxed()
} else {
prop_oneof![
4 => fuzz_expr(0),
2 => (fuzz_expr(depth - 1), prop_oneof![
Just("+"), Just("-"), Just("*"), Just("/"),
Just("="), Just("!="), Just("<"), Just("<="),
Just(">"), Just(">="), Just("AND"), Just("OR"),
], fuzz_expr(depth - 1))
.prop_map(|(l, op, r)| format!("({l} {op} {r})")),
1 => fuzz_expr(depth - 1).prop_map(|e| format!("(-{e})")),
1 => fuzz_expr(depth - 1).prop_map(|e| format!("(NOT {e})")),
1 => fuzz_expr(depth - 1).prop_map(|e| format!("ABS({e})")),
]
.boxed()
}
}
fn fuzz_predicate() -> BoxedStrategy<String> {
fuzz_expr(2)
}
fn fuzz_select_sql() -> BoxedStrategy<String> {
use std::fmt::Write as _;
(
prop::collection::vec(fuzz_expr(2), 1..=3),
prop::option::of(fuzz_predicate()),
prop::option::of(fuzz_column()),
prop::option::of(0_u8..=5),
)
.prop_map(|(cols, where_clause, order_by, limit)| {
let mut sql = format!("SELECT {} FROM t", cols.join(", "));
if let Some(pred) = where_clause {
write!(sql, " WHERE {pred}").expect("writing to String should not fail");
}
if let Some(col) = order_by {
write!(sql, " ORDER BY {col}").expect("writing to String should not fail");
}
if let Some(lim) = limit {
write!(sql, " LIMIT {lim}").expect("writing to String should not fail");
}
sql
})
.boxed()
}
fn fuzz_window_func() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("ROW_NUMBER"),
Just("RANK"),
Just("DENSE_RANK"),
Just("SUM"),
Just("COUNT"),
Just("AVG"),
]
}
fn fuzz_window_frame() -> impl Strategy<Value = String> {
prop_oneof![
Just("ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW".to_owned()),
Just("ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING".to_owned()),
Just("ROWS BETWEEN CURRENT ROW AND 2 FOLLOWING".to_owned()),
Just("RANGE BETWEEN CURRENT ROW AND CURRENT ROW".to_owned()),
Just("GROUPS BETWEEN CURRENT ROW AND CURRENT ROW".to_owned()),
]
}
fn fuzz_window_exclude() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just(""),
Just(" EXCLUDE CURRENT ROW"),
Just(" EXCLUDE TIES"),
Just(" EXCLUDE GROUP"),
]
}
fn fuzz_window_select_sql() -> BoxedStrategy<String> {
(
fuzz_window_func(),
fuzz_column(),
prop::option::of(Just("b")),
fuzz_window_frame(),
fuzz_window_exclude(),
)
.prop_map(|(func, order_col, partition_col, frame, exclude)| {
let over = if let Some(partition_col) = partition_col {
format!("PARTITION BY {partition_col} ORDER BY {order_col} {frame}{exclude}")
} else {
format!("ORDER BY {order_col} {frame}{exclude}")
};
let call = match func {
"ROW_NUMBER" | "RANK" | "DENSE_RANK" => format!("{func}() OVER ({over})"),
_ => format!("{func}(a) OVER ({over})"),
};
format!("SELECT a, b, {call} AS w FROM t")
})
.boxed()
}
fn fuzz_insert_sql() -> BoxedStrategy<String> {
(fuzz_literal(), fuzz_literal())
.prop_map(|(a, b)| format!("INSERT INTO t (a, b) VALUES ({a}, {b})"))
.boxed()
}
fn fuzz_update_sql() -> BoxedStrategy<String> {
(fuzz_column(), fuzz_expr(2), fuzz_predicate())
.prop_map(|(col, expr, pred)| format!("UPDATE t SET {col} = {expr} WHERE {pred}"))
.boxed()
}
fn fuzz_delete_sql() -> BoxedStrategy<String> {
fuzz_predicate()
.prop_map(|pred| format!("DELETE FROM t WHERE {pred}"))
.boxed()
}
fn fuzz_supported_sql() -> BoxedStrategy<String> {
prop_oneof![
4 => fuzz_select_sql(),
2 => fuzz_window_select_sql(),
2 => fuzz_insert_sql(),
1 => fuzz_update_sql(),
1 => fuzz_delete_sql(),
]
.boxed()
}
fn first_result_expr(select: &SelectStatement) -> &Expr {
let SelectCore::Select { columns, .. } = &select.body.select else {
unreachable!("expected SELECT core");
};
let ResultColumn::Expr { expr, .. } = &columns[0] else {
unreachable!("expected expression result column");
};
expr
}
#[test]
fn test_window_spec_placeholder_count_tracks_bounded_rows_frame_and_exclude_ties() {
let stmt = select_sql(
"SELECT SUM(a) OVER (PARTITION BY ? ORDER BY ? ROWS BETWEEN ? PRECEDING AND ? FOLLOWING EXCLUDE TIES) FROM t",
);
let Expr::FunctionCall {
over: Some(spec), ..
} = first_result_expr(&stmt)
else {
unreachable!("expected window function expression");
};
assert_eq!(count_anon_placeholders_in_window_spec(spec), 4);
let frame = spec.frame.as_ref().expect("window frame should exist");
assert_eq!(frame.exclude, Some(fsqlite_ast::FrameExclude::Ties));
}
#[test]
fn test_window_spec_placeholder_count_ignores_groups_current_row_exclude_group() {
let stmt = select_sql(
"SELECT SUM(a) OVER (ORDER BY a GROUPS BETWEEN CURRENT ROW AND CURRENT ROW EXCLUDE GROUP) FROM t",
);
let Expr::FunctionCall {
over: Some(spec), ..
} = first_result_expr(&stmt)
else {
unreachable!("expected window function expression");
};
assert_eq!(count_anon_placeholders_in_window_spec(spec), 0);
let frame = spec.frame.as_ref().expect("window frame should exist");
assert_eq!(frame.exclude, Some(fsqlite_ast::FrameExclude::Group));
}
#[test]
fn test_function_placeholder_count_includes_in_call_order_by_and_filter() {
let expr = expr_sql("group_concat(a ORDER BY ?) FILTER (WHERE b = ?)");
assert_eq!(
count_anon_placeholders(&expr),
2,
"function modifiers participate in lexical bind-slot accounting"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn test_parser_vdbe_codegen_supported_sql_no_panic(sql in fuzz_supported_sql()) {
let Some((statement, tail)) =
parse_first_statement_with_tail(&sql).expect("generated SQL should parse without parser panics")
else {
prop_assert!(false, "generator produced no statement: {}", sql);
return Ok(());
};
prop_assert_eq!(tail, sql.len(), "parser left trailing SQL for generated input: {}", sql);
let mut builder = ProgramBuilder::new();
let schema = test_schema();
let ctx = CodegenContext::default();
let result = match statement {
Statement::Select(stmt) => catch_unwind(AssertUnwindSafe(|| {
let _ = codegen_select(&mut builder, &stmt, &schema, &ctx);
})),
Statement::Insert(stmt) => catch_unwind(AssertUnwindSafe(|| {
let _ = codegen_insert(&mut builder, &stmt, &schema, &ctx);
})),
Statement::Update(stmt) => catch_unwind(AssertUnwindSafe(|| {
let _ = codegen_update(&mut builder, &stmt, &schema, &ctx);
})),
Statement::Delete(stmt) => catch_unwind(AssertUnwindSafe(|| {
let _ = codegen_delete(&mut builder, &stmt, &schema, &ctx);
})),
other => {
prop_assert!(
false,
"unsupported generated statement variant for sql {sql}: {other:?}"
);
return Ok(());
}
};
prop_assert!(result.is_ok(), "parser->vdbe codegen panicked for sql: {}", sql);
}
}
fn lower_name_eq_param(n: u32) -> Box<Expr> {
Box::new(expr_sql(&format!("lower(name) = ?{n}")))
}
fn lower_name_range_params(lower: u32, upper: u32) -> Box<Expr> {
Box::new(expr_sql(&format!(
"lower(name) >= ?{lower} AND lower(name) < ?{upper}"
)))
}
fn col_eq_param(col: &str, n: u32) -> Box<Expr> {
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare(col), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(n)),
span: Span::ZERO,
})
}
fn simple_select(
cols: &[&str],
table: &str,
where_clause: Option<Box<Expr>>,
) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: cols
.iter()
.map(|c| ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare(*c), Span::ZERO),
alias: None,
})
.collect(),
from: Some(from_table(table)),
where_clause,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn simple_select_as(
cols: &[&str],
table: &str,
alias: &str,
where_clause: Option<Box<Expr>>,
) -> SelectStatement {
let mut stmt = simple_select(cols, table, where_clause);
let SelectCore::Select { from, .. } = &mut stmt.body.select else {
unreachable!("simple_select always constructs a SELECT core");
};
*from = Some(from_table_as(table, alias));
stmt
}
fn star_select(table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn star_select_with_limit(table: &str, limit: i64) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(limit), Span::ZERO),
offset: None,
}),
}
}
fn star_select_with_limit_offset(table: &str, limit: i64, offset: i64) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(limit), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(offset), Span::ZERO)),
}),
}
}
/// bd-2dgf5 regression fixture: `t(id INTEGER PRIMARY KEY, k INTEGER, v TEXT)`
/// with a single-column ascending index on `k`.
///
/// Keep `type_name` populated so the fixture mirrors an ordinary parsed
/// schema. Comparison code nevertheless treats `ColumnInfo::affinity` as
/// authoritative; statement-local materialized schemas legitimately have
/// expression affinity while carrying no declared SQL type text.
fn bd_2dgf5_table() -> TableSchema {
let typed = |name: &str, affinity: char, is_ipk: bool, ty: &str| {
let mut col = ColumnInfo::basic(name, affinity, is_ipk);
col.type_name = Some(ty.to_owned());
col
};
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
typed("id", 'D', true, "INTEGER"),
typed("k", 'D', false, "INTEGER"),
typed("v", 'B', false, "TEXT"),
],
indexes: vec![IndexSchema {
name: "idx_t_k".to_owned(),
root_page: 3,
columns: vec!["k".to_owned()],
key_expressions: vec!["k".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
check_constraints: Vec::new(),
foreign_keys: Vec::new(),
}
}
/// bd-2dgf5. `cmp_p5` is `0x80 | comparison_affinity(...)`. For an INTEGER
/// column compared against an integer literal no affinity conversion is
/// needed, so the low bits are clear and the affinity gate in
/// `aggregate_index_eq_seek_target` ADMITS the seek.
///
/// This assertion is load-bearing: it is the reason the affinity gate can be
/// kept as cheap insurance against skewed comparisons (`k = '2'`) without
/// disabling the common indexed-integer-equality case. If it ever flips,
/// the aggregate index seek silently degrades to a full scan and the
/// bd-2dgf5 speedup disappears with no test failure elsewhere.
#[test]
fn resolved_index_range_comparison_carries_affinity_for_integer_column() {
let table = bd_2dgf5_table();
let schema = vec![table.clone()];
let target = Expr::Literal(Literal::Integer(2), Span::ZERO);
let comparison = resolved_index_range_comparison(&table, None, &schema, "k", &target);
assert_eq!(
comparison.cmp_p5 & 0x80,
0x80,
"the 0x80 bit is always set by ResolvedComparisonInfo::new"
);
assert_ne!(
comparison.cmp_p5 & !0x80,
0,
"an INTEGER column compared against an integer literal DOES carry a \
comparison affinity; gating the aggregate index seek on \
`(cmp_p5 & !0x80) == 0` therefore rejects exactly the case it was \
meant to accelerate, turning the seek into a silent no-op"
);
}
/// bd-2dgf5: the aggregate seek must engage for a plain single-column
/// ascending index and must decline everything it cannot probe with a
/// one-key-term + rowid record.
#[test]
fn aggregate_index_eq_seek_target_contract() {
let table = bd_2dgf5_table();
let two = Expr::Literal(Literal::Integer(2), Span::ZERO);
let indexed_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("k"), Span::ZERO)),
op: fsqlite_ast::BinaryOp::Eq,
right: Box::new(two.clone()),
span: Span::ZERO,
};
let (idx, targets) = aggregate_index_eq_seek_target(Some(&indexed_eq), &table, None)
.expect("indexed column equality must select the index seek");
assert_eq!(idx.name, "idx_t_k");
assert_eq!(targets.len(), 1);
assert!(matches!(targets[0], Expr::Literal(Literal::Integer(2), _)));
// Unindexed column: no seek.
let unindexed_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("v"), Span::ZERO)),
op: fsqlite_ast::BinaryOp::Eq,
right: Box::new(two.clone()),
span: Span::ZERO,
};
assert!(aggregate_index_eq_seek_target(Some(&unindexed_eq), &table, None).is_none());
// Rowid / INTEGER PRIMARY KEY has no secondary index: no seek here.
let rowid_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("id"), Span::ZERO)),
op: fsqlite_ast::BinaryOp::Eq,
right: Box::new(two.clone()),
span: Span::ZERO,
};
assert!(aggregate_index_eq_seek_target(Some(&rowid_eq), &table, None).is_none());
// No WHERE clause at all: no seek.
assert!(aggregate_index_eq_seek_target(None, &table, None).is_none());
// Non-equality predicate: no seek.
let range = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("k"), Span::ZERO)),
op: fsqlite_ast::BinaryOp::Gt,
right: Box::new(two),
span: Span::ZERO,
};
assert!(aggregate_index_eq_seek_target(Some(&range), &table, None).is_none());
}
#[test]
fn in_list_residual_detection_and_routing() {
let table = bd_2dgf5_table();
// Detection: `k IN (1, 2) AND v = 3` — an IN-list CONJUNCT plus a residual.
let e = parse_sql_expr("k IN (1, 2) AND v = 3").expect("parse");
let r = index_integer_in_list_residual_target(Some(&e), &table, None);
assert!(r.is_some(), "IN-list + residual must be detected");
let (idx, ints, has_residual) = r.unwrap();
assert_eq!(idx.name, "idx_t_k");
assert_eq!(ints, vec![1, 2]);
assert!(has_residual);
// Residual-free still reported (has_residual = false).
let e2 = parse_sql_expr("k IN (1, 2)").expect("parse");
assert_eq!(
index_integer_in_list_residual_target(Some(&e2), &table, None).map(|(_, _, h)| h),
Some(false)
);
// Routing: COUNT(*) with an IN-list + residual must reach the seek (SeekGE in the program).
let ops = bd_2dgf5_program("SELECT COUNT(*) FROM t WHERE k IN (1, 2) AND v = 3");
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"COUNT(*) IN-list + residual must seek (SeekGE), got: {:?}",
ops.iter().map(|o| o.opcode).collect::<Vec<_>>()
);
}
/// Compile `sql` against [`bd_2dgf5_table`] and return the emitted opcodes.
fn bd_2dgf5_program(sql: &str) -> Vec<VdbeOp> {
let stmt = select_sql(sql);
let schema = vec![bd_2dgf5_table()];
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default())
.expect("bd-2dgf5 fixture SELECT should compile");
b.finish().expect("program should finish").ops().to_vec()
}
#[test]
fn single_group_aggregate_limit_preserves_textual_placeholder_indices() {
let ops = bd_2dgf5_program("SELECT COUNT(*) FROM t WHERE id > ? LIMIT ?");
let variable_indices: Vec<i32> = ops
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(
variable_indices,
vec![2, 1],
"LIMIT is emitted before the scan, but its anonymous parameter must retain \
its second-in-SQL index"
);
let limit_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 2)
.expect("LIMIT parameter must be emitted");
assert_eq!(
ops.get(limit_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"dynamic LIMIT must be validated as a losslessly coercible integer"
);
let ops = bd_2dgf5_program("SELECT COUNT(*) FROM t LIMIT ? OFFSET ?");
let variable_indices: Vec<i32> = ops
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(variable_indices, vec![1, 2]);
for variable in ops
.iter()
.enumerate()
.filter(|(_, op)| op.opcode == Opcode::Variable)
.map(|(index, _)| index)
{
assert_eq!(
ops.get(variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"dynamic LIMIT and OFFSET must both receive integer validation"
);
}
}
#[test]
fn grouped_aggregate_limit_validates_and_preserves_numbered_slots() {
let ops = bd_2dgf5_program(
"SELECT k, COUNT(*) FROM t WHERE v = ?1 GROUP BY k LIMIT ?2 OFFSET ?3",
);
let variables: Vec<i32> = ops
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(
variables,
vec![2, 3, 1],
"GROUP BY codegen emits LIMIT/OFFSET before the scan, but numbered \
parameters must keep their SQL-text slots"
);
let limit_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 2)
.expect("GROUP BY LIMIT parameter must be emitted");
assert_eq!(
ops.get(limit_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"GROUP BY LIMIT must be losslessly coerced to integer"
);
let zero_guard = ops
.iter()
.enumerate()
.skip(limit_variable + 1)
.find(|(_, op)| op.opcode == Opcode::IfNot)
.map(|(index, _)| index)
.expect("GROUP BY LIMIT zero guard must be emitted");
let offset_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 3)
.expect("GROUP BY OFFSET parameter must be emitted");
assert!(
zero_guard < offset_variable,
"GROUP BY LIMIT zero must short-circuit before OFFSET evaluation"
);
assert_eq!(
ops.get(offset_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"GROUP BY OFFSET must be losslessly coerced to integer"
);
let integer_literal_ops =
bd_2dgf5_program("SELECT k, COUNT(*) FROM t GROUP BY k LIMIT 2 OFFSET 1");
assert!(
!integer_literal_ops
.iter()
.any(|op| op.opcode == Opcode::MustBeInt),
"exact integer LIMIT/OFFSET literals must avoid redundant coercion opcodes"
);
let text_literal_ops =
bd_2dgf5_program("SELECT k, COUNT(*) FROM t GROUP BY k LIMIT '2' OFFSET '1'");
assert_eq!(
text_literal_ops
.iter()
.filter(|op| op.opcode == Opcode::MustBeInt)
.count(),
2,
"coercible text LIMIT/OFFSET literals must each be validated at runtime"
);
}
#[test]
fn nonaggregate_eq_residual_limit_validates_and_preserves_numbered_slots() {
let ops = bd_2dgf5_program("SELECT id FROM t WHERE k = 2 AND v = ?1 LIMIT ?2 OFFSET ?3");
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_k")),
"canonicalized/numbered residual + LIMIT/OFFSET must retain the equality seek"
);
let variables: Vec<i32> = ops
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(
variables,
vec![2, 3, 1, 1],
"LIMIT and OFFSET emit first, but must retain their second/third textual slots; \
the residual is emitted once per mutually-exclusive seek/fallback path at slot 1"
);
let limit_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 2)
.expect("LIMIT parameter must be emitted");
assert_eq!(
ops.get(limit_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"dynamic equality-scan LIMIT must be losslessly coerced to integer"
);
let zero_guard = ops
.iter()
.enumerate()
.skip(limit_variable + 1)
.find(|(_, op)| op.opcode == Opcode::IfNot)
.map(|(index, _)| index)
.expect("LIMIT zero guard must be emitted");
let offset_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 3)
.expect("OFFSET parameter must be emitted");
assert!(
zero_guard < offset_variable,
"LIMIT zero must short-circuit before OFFSET is evaluated"
);
assert_eq!(
ops.get(offset_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"dynamic equality-scan OFFSET must be losslessly coerced to integer"
);
}
#[test]
fn nonaggregate_eq_residual_raw_non_numbered_slots_decline_seek() {
for sql in [
"SELECT id FROM t WHERE k = 2 AND v = ? LIMIT ? OFFSET ?",
"SELECT id FROM t WHERE k = 2 AND v = :residual LIMIT :lim OFFSET :off",
] {
let ops = bd_2dgf5_program(sql);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_k")),
"raw anonymous/named AST must decline the residual optimization until \
connection-level canonicalization assigns stable slots: `{sql}`"
);
}
}
/// Index of the first instruction of the scan fallback.
///
/// The fallback always opens the table and immediately rewinds it. The
/// *non-covering* seek also opens the table, but follows that with the index
/// `OpenRead`, never a `Rewind` — so "first `OpenRead` of the table" is not the
/// boundary, and using it silently yields an empty fast path.
fn bd_2dgf5_fallback_start(ops: &[VdbeOp]) -> usize {
ops.windows(2)
.position(|pair| {
pair[0].opcode == Opcode::OpenRead
&& matches!(&pair[0].p4, P4::Table(name) if name == "t")
&& pair[1].opcode == Opcode::Rewind
})
.unwrap_or(ops.len())
}
/// The seek fast path: everything the program runs before falling back to a scan.
fn bd_2dgf5_seek_fast_path(ops: &[VdbeOp]) -> &[VdbeOp] {
&ops[..bd_2dgf5_fallback_start(ops)]
}
/// bd-2dgf5: an aggregate over `<ipk> = <int literal>` must seek the single row by
/// rowid (`SeekRowid`) with no `Rewind`/`Next` table walk. The neighbouring shapes
/// that carry affinity or are not a bare rowid equality must decline the seek and
/// keep scanning, so the seek stays exact. Asserted on the emitted program because
/// EQP renders separately and can disagree (bd-jyyae); the differential oracle test
/// `rowid_eq_aggregate_matches_sqlite` covers the results.
#[test]
fn bd_2dgf5_rowid_equality_aggregate_seeks_single_row() {
// Scope: a POSITIVE integer literal on the rowid. `SUM`/`MIN`/`COUNT(col)` route
// through codegen_select_aggregate (COUNT(*) is intercepted elsewhere).
for sql in [
"SELECT SUM(v) FROM t WHERE id = 2",
"SELECT SUM(k) FROM t WHERE 2 = id",
"SELECT MIN(v) FROM t WHERE id = 2",
"SELECT COUNT(k) FROM t WHERE id = 100",
] {
let ops = bd_2dgf5_program(sql);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"`{sql}` must seek the row by rowid"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"`{sql}` is a unique rowid point lookup: it must not scan (no Rewind)"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::AggStep),
"`{sql}` must still accumulate the seeked row"
);
}
// A secondary-index equality plus a rowid residual takes the later
// equality-prefix residual path. It must seek the index, visit the table row,
// re-apply the full WHERE, and never fall back to a table scan.
let residual_ops = bd_2dgf5_program("SELECT SUM(v) FROM t WHERE id = 2 AND k = 3");
assert!(
residual_ops
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_t_k")),
"indexed residual path must open idx_t_k"
);
for required in [
Opcode::SeekGE,
Opcode::IdxRowid,
Opcode::SeekRowid,
Opcode::AggStep,
Opcode::Next,
] {
assert!(
residual_ops.iter().any(|op| op.opcode == required),
"indexed residual path must emit {required:?}"
);
}
assert!(
!residual_ops.iter().any(|op| op.opcode == Opcode::Rewind),
"indexed residual path must not full-scan the table"
);
// Non-integer literal constants use the newer exact-or-reject rowid
// probe: MustBeInt converts values such as '2' losslessly and jumps to
// aggregate finalization for a non-integral real such as 2.5. Both
// shapes avoid a table scan, as covered by agg_rowid_eq_coerced_oracle.
for sql in [
"SELECT SUM(v) FROM t WHERE id = 2.5",
"SELECT SUM(v) FROM t WHERE id = '2'",
] {
let ops = bd_2dgf5_program(sql);
assert!(
ops.iter().any(|op| op.opcode == Opcode::MustBeInt),
"`{sql}` must coerce the rowid probe exactly"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"`{sql}` must use the exact-or-reject rowid probe"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must not scan after exact-or-reject coercion"
);
}
// A rowid equality with an unindexed residual still probes the unique
// row directly and re-applies the full predicate before AggStep.
let residual_ops = bd_2dgf5_program("SELECT SUM(v) FROM t WHERE id = 2 AND v = '3'");
assert!(
residual_ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"rowid equality plus residual must still use the unique rowid probe"
);
assert!(
residual_ops.iter().any(|op| op.opcode == Opcode::AggStep),
"rowid equality plus residual must retain aggregate accumulation"
);
assert!(
!residual_ops.iter().any(|op| op.opcode == Opcode::Rewind),
"rowid equality plus residual must not scan the table"
);
// Shapes the direct rowid seek still MUST decline. `id = -5` parses as
// unary-negate over an integer literal (not a simple constant), while
// NOT INDEXED explicitly retains the scan contract.
for sql in [
"SELECT SUM(v) FROM t WHERE id = -5",
"SELECT SUM(v) FROM t NOT INDEXED WHERE id = 2",
] {
let ops = bd_2dgf5_program(sql);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must decline the rowid seek and scan (Rewind present)"
);
}
}
/// bd-2dgf5: an aggregate over a rowid *range* must position with `SeekGE`/`SeekGT`
/// on a lower bound (not a full `Rewind` of every row), and an upper bound must add a
/// `Gt`/`Ge` early-exit. Results are covered by `rowid_range_aggregate_matches_sqlite`.
#[test]
fn bd_2dgf5_rowid_range_aggregate_positions_and_bounds() {
// Lower-bounded ranges seek to the start instead of rewinding.
for sql in [
"SELECT SUM(v) FROM t WHERE id >= 3",
"SELECT COUNT(k) FROM t WHERE id > 3",
"SELECT SUM(k) FROM t WHERE id BETWEEN 2 AND 5",
] {
let ops = bd_2dgf5_program(sql);
assert!(
ops.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"`{sql}` must seek to the lower bound"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"`{sql}` has a lower bound: it must not rewind the whole table"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::AggStep),
"`{sql}` must still accumulate"
);
}
// Upper-bounded ranges early-exit once the cursor passes the bound.
for sql in [
"SELECT SUM(v) FROM t WHERE id <= 3",
"SELECT COUNT(*) FROM t WHERE id < 3",
] {
let ops = bd_2dgf5_program(sql);
assert!(
ops.iter()
.any(|op| matches!(op.opcode, Opcode::Gt | Opcode::Ge)),
"`{sql}` must emit an upper-bound early-exit (Gt/Ge)"
);
}
// GROUP BY / extra predicates / NOT INDEXED must not take the lone-range path.
let grouped = bd_2dgf5_program("SELECT SUM(k) FROM t WHERE id <= 3 GROUP BY k");
assert!(
grouped.iter().any(|op| op.opcode == Opcode::Rewind),
"GROUP BY must not take the rowid-range aggregate path"
);
}
/// bd-2dgf5: `SUM(v) FROM t WHERE k IN (<int literals>)` (k INTEGER-affinity, indexed)
/// seeks the index once per DISTINCT value and never full-scans. Duplicate values are
/// de-duped (one seek). Results are covered by `index_in_list_aggregate_matches_sqlite`.
#[test]
fn bd_2dgf5_index_in_list_aggregate_seeks_per_distinct_value() {
let seeks = |sql: &str| -> usize {
bd_2dgf5_program(sql)
.iter()
.filter(|op| op.opcode == Opcode::SeekGE)
.count()
};
// Three distinct values -> three seeks, no Rewind.
let ops = bd_2dgf5_program("SELECT SUM(v) FROM t WHERE k IN (1, 2, 3)");
assert_eq!(
ops.iter().filter(|op| op.opcode == Opcode::SeekGE).count(),
3,
"k IN (1,2,3) must seek once per distinct value"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"k IN (1,2,3) must not full-scan"
);
assert!(ops.iter().any(|op| op.opcode == Opcode::AggStep));
// Duplicates collapse to a single seek.
assert_eq!(seeks("SELECT SUM(v) FROM t WHERE k IN (2, 2, 2)"), 1);
assert_eq!(seeks("SELECT COUNT(k) FROM t WHERE k IN (1, 1, 2)"), 2);
// Shapes the seek MUST decline (must Rewind-scan): text/real elements, NOT IN, extra
// predicate, GROUP BY, NOT INDEXED, and a non-INTEGER-affinity column.
for sql in [
"SELECT SUM(v) FROM t WHERE k IN (2, 3.0)",
"SELECT SUM(v) FROM t WHERE k IN ('2', '3')",
"SELECT SUM(v) FROM t WHERE k NOT IN (1, 2)",
"SELECT SUM(v) FROM t WHERE k IN (1, 2) AND id < 3",
"SELECT SUM(v) FROM t WHERE k IN (1, 2) GROUP BY k",
"SELECT SUM(v) FROM t NOT INDEXED WHERE k IN (1, 2)",
// `v` is TEXT-affinity in the fixture: an integer-list seek would be unsafe.
"SELECT SUM(k) FROM t WHERE v IN (1, 2)",
] {
assert!(
bd_2dgf5_program(sql)
.iter()
.any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must decline the IN-list seek and scan (Rewind present)"
);
}
}
/// bd-2dgf5: non-aggregate `SELECT <cols> FROM t WHERE <int col> IN (<int literals>)`
/// seeks the index once per DISTINCT value and never full-scans; ORDER BY / LIMIT /
/// DISTINCT / a non-INTEGER column / NOT IN decline to the scan. Results are covered by
/// `index_in_list_nonaggregate_matches_sqlite`.
#[test]
fn bd_2dgf5_nonaggregate_in_list_seeks_per_distinct_value() {
let seeks = |sql: &str| -> usize {
bd_2dgf5_program(sql)
.iter()
.filter(|op| op.opcode == Opcode::SeekGE)
.count()
};
let ops = bd_2dgf5_program("SELECT id, v FROM t WHERE k IN (1, 2, 3)");
assert_eq!(seeks("SELECT id, v FROM t WHERE k IN (1, 2, 3)"), 3);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"k IN (1,2,3) must not full-scan"
);
assert!(ops.iter().any(|op| op.opcode == Opcode::ResultRow));
assert_eq!(seeks("SELECT id FROM t WHERE k IN (2, 2)"), 1);
for sql in [
"SELECT id FROM t WHERE k IN (1, 2) ORDER BY id",
"SELECT id FROM t WHERE k IN (1, 2) LIMIT 3",
"SELECT DISTINCT id FROM t WHERE k IN (1, 2)",
"SELECT id FROM t WHERE k IN (2, 3.0)",
"SELECT id FROM t WHERE k NOT IN (1, 2)",
"SELECT id FROM t WHERE v IN (1, 2)",
"SELECT id FROM t NOT INDEXED WHERE k IN (1, 2)",
] {
assert!(
bd_2dgf5_program(sql)
.iter()
.any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must decline the non-aggregate IN-list seek (Rewind present)"
);
}
}
/// bd-2dgf5: `SELECT <cols> FROM t WHERE <rowid> IN (<int literals>)` does one `SeekRowid`
/// per distinct value and never full-scans. A single rowid ASC/DESC ordering is served by
/// the sorted seek sequence; LIMIT / DISTINCT / unsupported ORDER BY / non-integer or
/// negative elements / NOT IN decline to a Rewind scan. Covered by
/// `rowid_in_list_matches_sqlite`.
#[test]
fn bd_2dgf5_rowid_in_list_point_lookups() {
let seek_rowids = |sql: &str| -> usize {
bd_2dgf5_program(sql)
.iter()
.filter(|op| op.opcode == Opcode::SeekRowid)
.count()
};
let ops = bd_2dgf5_program("SELECT id, v FROM t WHERE id IN (5, 10, 15)");
assert_eq!(
seek_rowids("SELECT id, v FROM t WHERE id IN (5, 10, 15)"),
3
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"id IN (5,10,15) must not full-scan"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"rowid IN uses SeekRowid, not an index SeekGE"
);
assert!(ops.iter().any(|op| op.opcode == Opcode::ResultRow));
assert_eq!(seek_rowids("SELECT id FROM t WHERE id IN (5, 5, 10)"), 2);
for sql in [
"SELECT id FROM t WHERE id IN (5, 10) ORDER BY id ASC",
"SELECT id FROM t WHERE id IN (5, 10) ORDER BY id DESC",
] {
let ordered_ops = bd_2dgf5_program(sql);
assert_eq!(
ordered_ops
.iter()
.filter(|op| op.opcode == Opcode::SeekRowid)
.count(),
2,
"`{sql}` must seek each distinct rowid"
);
assert!(
!ordered_ops.iter().any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must not full-scan"
);
assert!(
!ordered_ops.iter().any(|op| op.opcode == Opcode::SorterOpen),
"`{sql}` must not open a sorter"
);
}
for sql in [
"SELECT id FROM t WHERE id IN (5, 10) LIMIT 1",
"SELECT DISTINCT id FROM t WHERE id IN (5, 10)",
"SELECT id FROM t WHERE id IN (2.0, 5)",
"SELECT id FROM t WHERE id IN (-5, 2)",
"SELECT id FROM t WHERE id NOT IN (5, 10)",
"SELECT id FROM t NOT INDEXED WHERE id IN (5, 10)",
] {
assert!(
bd_2dgf5_program(sql)
.iter()
.any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must decline the rowid IN-list seek (Rewind present)"
);
}
}
/// bd-2dgf5: `col = a OR col = b OR ...` on one indexed column normalizes to the IN-list
/// seek (secondary index -> SeekGE per value; rowid -> SeekRowid per value). Mixed columns,
/// mixed operators, NOT IN, real literals, and AND-mixed precedence decline to the scan.
/// Results are covered by `or_of_equalities_matches_sqlite`.
#[test]
fn bd_2dgf5_or_of_equalities_normalizes_to_seek() {
// Secondary index (k): SeekGE per distinct value, no Rewind.
let ki = bd_2dgf5_program("SELECT id FROM t WHERE k = 2 OR k = 5");
assert_eq!(
ki.iter().filter(|op| op.opcode == Opcode::SeekGE).count(),
2,
"k = 2 OR k = 5 must seek per distinct value"
);
assert!(!ki.iter().any(|op| op.opcode == Opcode::Rewind));
// Duplicate values collapse.
assert_eq!(
bd_2dgf5_program("SELECT id FROM t WHERE k = 2 OR k = 2")
.iter()
.filter(|op| op.opcode == Opcode::SeekGE)
.count(),
1
);
// Rowid (id): SeekRowid per distinct value.
let ri = bd_2dgf5_program("SELECT id FROM t WHERE id = 2 OR id = 3");
assert_eq!(
ri.iter()
.filter(|op| op.opcode == Opcode::SeekRowid)
.count(),
2
);
assert!(!ri.iter().any(|op| op.opcode == Opcode::Rewind));
for sql in [
"SELECT id FROM t WHERE k = 2 OR v = 3",
"SELECT id FROM t WHERE k = 2 OR k > 5",
"SELECT id FROM t WHERE k = 2.0 OR k = 3",
"SELECT id FROM t WHERE k = 2 OR k = 3 AND id < 5",
"SELECT id FROM t WHERE k = 2 OR k = 3 ORDER BY id",
"SELECT id FROM t NOT INDEXED WHERE k = 2 OR k = 3",
] {
assert!(
bd_2dgf5_program(sql)
.iter()
.any(|op| op.opcode == Opcode::Rewind),
"`{sql}` must decline OR normalization and scan (Rewind present)"
);
}
}
/// bd-2dgf5: a covering aggregate seek must read the index entry and nothing
/// else — no table cursor, no `SeekRowid`.
///
/// This asserts on the *emitted program*, not on `EXPLAIN QUERY PLAN` text.
/// EQP renders from the planner directive rather than from the program, and
/// the two diverge in both directions (bd-jyyae), so EQP can report a plan the
/// program never executes. Opcodes are the only ground truth here.
#[test]
fn bd_2dgf5_covering_aggregate_seek_omits_table_lookup() {
// COUNT(*) needs no column at all; SUM(id) reads the rowid, which the index
// entry carries via IdxRowid; SUM(k) reads the index key itself.
for sql in [
"SELECT COUNT(*) FROM t WHERE k = 2",
"SELECT SUM(id) FROM t WHERE k = 2",
"SELECT SUM(k) FROM t WHERE k = 2",
"SELECT MIN(id), MAX(id) FROM t WHERE k = 2",
"SELECT COUNT(*) FROM t WHERE k = 2 ORDER BY 1",
] {
let ops = bd_2dgf5_program(sql);
let fast_path = bd_2dgf5_seek_fast_path(&ops);
assert!(
fast_path.iter().any(|op| op.opcode == Opcode::SeekGE),
"`{sql}` must seek the index rather than scan"
);
assert!(
!fast_path.iter().any(|op| op.opcode == Opcode::SeekRowid),
"`{sql}` is covering: it must not look the row up in the table"
);
assert!(
!fast_path.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Table(name) if name == "t")),
"`{sql}` is covering: it must not open the table cursor at all"
);
assert!(
fast_path.iter().any(|op| op.opcode == Opcode::AggStep),
"`{sql}` must still accumulate over the seeked run"
);
}
}
/// bd-2dgf5: an aggregate that needs a column the index does not carry must
/// keep the table lookup. `v` is not in `idx_t_k`, so `SUM(v)` still seeks the
/// index and then fetches the row.
///
/// This is the negative half of the covering contract: without it, a bug that
/// declared everything covering would silently read `v` from the wrong cursor
/// and every assertion in the positive test would still pass.
#[test]
fn bd_2dgf5_non_covering_aggregate_seek_keeps_table_lookup() {
for sql in [
"SELECT SUM(v) FROM t WHERE k = 2",
"SELECT group_concat(v) FROM t WHERE k = 2",
"SELECT COUNT(*) FILTER (WHERE id > 100) FROM t WHERE k = 2",
] {
let ops = bd_2dgf5_program(sql);
let fast_path = bd_2dgf5_seek_fast_path(&ops);
assert!(
fast_path.iter().any(|op| op.opcode == Opcode::SeekGE),
"`{sql}` should still seek the index"
);
assert!(
fast_path.iter().any(|op| op.opcode == Opcode::SeekRowid),
"`{sql}` needs a column the index does not carry, so it must \
look the row up in the table"
);
}
}
/// bd-2dgf5: the seek body and the scan-fallback body must emit the *same*
/// `AggStep` sequence — same DISTINCT flag, same function P4, same argument
/// count. Only the instruction that loads the argument may differ
/// (`IdxRowid`/`Column idx` vs `Rowid`/`Column table`).
///
/// Every seek program carries both bodies: the seek runs first, and the scan
/// fallback follows for the probe-matched-nothing case. They are emitted by two
/// different functions, so nothing but this test stops them from drifting — and
/// if they drift, the same query returns different answers depending on whether
/// the probe hit, which no value test on a populated table would catch.
#[test]
fn bd_2dgf5_seek_and_fallback_bodies_emit_identical_agg_steps() {
for sql in [
"SELECT COUNT(*) FROM t WHERE k = 2",
"SELECT SUM(id) FROM t WHERE k = 2",
"SELECT SUM(k) FROM t WHERE k = 2",
"SELECT COUNT(DISTINCT k) FROM t WHERE k = 2",
"SELECT MIN(id), MAX(id) FROM t WHERE k = 2",
"SELECT SUM(v) FROM t WHERE k = 2",
] {
let ops = bd_2dgf5_program(sql);
let split = bd_2dgf5_fallback_start(&ops);
assert!(
split < ops.len(),
"`{sql}` should take the seek path and emit a scan fallback"
);
let agg_steps = |slice: &[VdbeOp]| -> Vec<(i32, P4, u16)> {
slice
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.map(|op| (op.p1, op.p4.clone(), op.p5))
.collect()
};
let seek_body = agg_steps(&ops[..split]);
let fallback_body = agg_steps(&ops[split..]);
assert!(
!seek_body.is_empty(),
"`{sql}` seek body must accumulate at least one aggregate"
);
assert_eq!(
seek_body, fallback_body,
"AggStep drifted between the seek body and the scan-fallback body \
for `{sql}`"
);
}
}
fn opcode_sequence(prog: &crate::VdbeProgram) -> Vec<Opcode> {
prog.ops().iter().map(|op| op.opcode).collect()
}
fn has_opcodes(prog: &crate::VdbeProgram, expected: &[Opcode]) -> bool {
let ops = opcode_sequence(prog);
// Check that expected opcodes appear in order (not necessarily adjacent).
let mut ops_iter = ops.iter();
for expected_op in expected {
if !ops_iter.any(|op| op == expected_op) {
return false;
}
}
true
}
fn schema_with_ipk_alias() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', true),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn schema_with_ipk_and_strict_real_notnull() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo {
name: "id".to_owned(),
affinity: 'D',
is_ipk: true,
type_name: Some("INTEGER".to_owned()),
notnull: true,
unique: true,
default_value: None,
strict_type: Some(StrictColumnType::Integer),
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "score".to_owned(),
affinity: 'E',
is_ipk: false,
type_name: Some("REAL".to_owned()),
notnull: true,
unique: false,
default_value: None,
strict_type: Some(StrictColumnType::Real),
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: true,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn schema_with_ipk_and_strict_text() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo {
name: "id".to_owned(),
affinity: 'D',
is_ipk: true,
type_name: Some("INTEGER".to_owned()),
notnull: true,
unique: true,
default_value: None,
strict_type: Some(StrictColumnType::Integer),
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "payload".to_owned(),
affinity: 'B',
is_ipk: false,
type_name: Some("TEXT".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: Some(StrictColumnType::Text),
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: true,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn schema_with_visible_rowid_column() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("rowid", 'C', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn schema_with_without_rowid_ipk() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', true),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: true,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn schema_with_visible_rowid_column_and_a_indexes() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("rowid", 'C', false),
],
indexes: vec![
IndexSchema {
name: "idx_t_a".to_owned(),
root_page: 3,
columns: vec!["a".to_owned()],
key_expressions: vec!["a".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "idx_t_rowid".to_owned(),
root_page: 4,
columns: vec!["rowid".to_owned()],
key_expressions: vec!["rowid".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
// === Test 1: SELECT by rowid ===
#[test]
fn test_codegen_select_by_rowid() {
let stmt = simple_select(&["b"], "t", Some(rowid_eq_param()));
let schema = test_schema();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-messages-prefix-equality".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "messages".to_owned(),
index_name: Some("sqlite_autoindex_messages_1".to_owned()),
index_key_label: Some("conversation_id".to_owned()),
index_key_is_expression: false,
index_equality_target: None,
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::Variable,
Opcode::OpenRead,
Opcode::SeekRowid,
Opcode::Column,
Opcode::ResultRow,
Opcode::Close,
Opcode::Halt,
]
));
// Transaction should be read-only (p2=0).
let txn = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Transaction)
.unwrap();
assert_eq!(txn.p2, 0);
}
#[test]
fn test_codegen_b3_rowid_and_ipk_equality_use_direct_seek() {
let cases = [
(
"hidden rowid",
simple_select(&["b"], "t", Some(rowid_eq_param())),
test_schema(),
),
(
"INTEGER PRIMARY KEY alias",
simple_select(&["b"], "t", Some(col_cmp_param("a", AstBinaryOp::Eq, 1))),
schema_with_ipk_alias(),
),
];
for (name, stmt, schema) in cases {
let mut b = ProgramBuilder::new();
if let Err(err) = codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()) {
assert!(test_failure(), "{name} SELECT should codegen: {err:?}");
continue;
}
let prog = match b.finish() {
Ok(prog) => prog,
Err(err) => {
assert!(test_failure(), "{name} program should finish: {err:?}");
continue;
}
};
let ops = prog.ops();
let Some(seek_pos) = ops.iter().position(|op| op.opcode == Opcode::SeekRowid) else {
assert!(test_failure(), "{name} lookup must emit SeekRowid");
continue;
};
let Some(column_pos) = ops.iter().position(|op| op.opcode == Opcode::Column) else {
assert!(test_failure(), "{name} lookup must read the matched row");
continue;
};
assert!(
seek_pos < column_pos,
"{name} lookup must seek before reading output columns"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::Rewind | Opcode::Next)),
"{name} equality lookup must not carry a full-scan loop"
);
}
}
#[test]
fn test_codegen_b3_index_equality_uses_index_probe_before_any_scan_fallback() {
let stmt = simple_select(&["a"], "t", Some(col_cmp_param("b", AstBinaryOp::Eq, 1)));
let schema = test_schema_with_index();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let seek_ge_pos = ops
.iter()
.position(|op| op.opcode == Opcode::SeekGE && op.p1 == 1)
.expect("indexed equality should seek into the index cursor");
let first_table_rewind = ops
.iter()
.position(|op| op.opcode == Opcode::Rewind && op.p1 == 0)
.unwrap_or(ops.len());
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")
}),
"indexed equality should open the matching index"
);
assert!(
seek_ge_pos < first_table_rewind,
"indexed equality must probe the index before any scan fallback"
);
assert!(
ops[..first_table_rewind]
.iter()
.any(|op| op.opcode == Opcode::IdxRowid && op.p1 == 1),
"non-covering indexed equality should recover table rowids from the index"
);
assert!(
ops[..first_table_rewind]
.iter()
.any(|op| op.opcode == Opcode::SeekRowid && op.p1 == 0),
"non-covering indexed equality should seek the table by rowid, not scan it"
);
}
#[test]
fn test_codegen_b3_covering_index_fast_path_projects_without_table_lookup() {
let stmt = simple_select(&["b"], "t", Some(col_cmp_param("b", AstBinaryOp::Eq, 1)));
let schema = test_schema_with_index();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let seek_ge_pos = ops
.iter()
.position(|op| op.opcode == Opcode::SeekGE && op.p1 == 1)
.expect("covering equality should seek into the index cursor");
let fast_path_end = ops
.iter()
.position(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "t")
})
.unwrap_or(ops.len());
assert!(
seek_ge_pos < fast_path_end,
"covering equality must run the index probe before any scan fallback"
);
assert!(
!ops[..fast_path_end]
.iter()
.any(|op| op.opcode == Opcode::SeekRowid),
"covering equality fast path should not perform table rowid lookups"
);
assert!(
ops[..fast_path_end]
.iter()
.any(|op| op.opcode == Opcode::Column && op.p1 == 1),
"covering equality should project output from the index cursor"
);
}
#[test]
fn test_codegen_select_ipk_range_uses_bounded_seek_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Ge,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
}),
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Lt,
right: Box::new(Expr::Literal(Literal::Integer(2), Span::ZERO)),
span: Span::ZERO,
}),
)),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"range scan should position with SeekGE instead of a full-table Rewind"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Ge),
"exclusive upper bound should stop once current rowid reaches the high key"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"bounded rowid/IPK range should not fall back to a full-table rewind"
);
let next = ops
.iter()
.find(|op| op.opcode == Opcode::Next)
.expect("bounded range scan should advance with Next");
assert_eq!(next.p1, 0, "Next should advance the table cursor");
}
#[test]
fn test_codegen_select_ipk_between_uses_seek_and_upper_guard() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Between {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
low: Box::new(Expr::Literal(Literal::Integer(10), Span::ZERO)),
high: Box::new(Expr::Literal(Literal::Integer(20), Span::ZERO)),
not: false,
span: Span::ZERO,
})),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"BETWEEN should reuse the rowid/IPK range seek path"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Gt),
"inclusive upper bound should stop only after the current rowid exceeds the high key"
);
}
#[test]
fn test_codegen_select_ipk_range_with_anonymous_params_stays_on_full_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Ge,
right: Box::new(anonymous_placeholder()),
span: Span::ZERO,
}),
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Lt,
right: Box::new(anonymous_placeholder()),
span: Span::ZERO,
}),
)),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::Rewind),
"anonymous placeholder ranges must stay on the original full-scan path"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"fast-path range seek must not reorder anonymous placeholders"
);
}
#[test]
fn test_codegen_select_ipk_range_with_numbered_params_uses_bounded_seek_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
col_cmp_param("a", AstBinaryOp::Ge, 1),
col_cmp_param("a", AstBinaryOp::Lt, 2),
)),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"numbered rowid/IPK ranges should reuse the bounded seek fast path"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Rewind),
"numbered rowid/IPK ranges should no longer fall back to a full-table rewind"
);
}
#[test]
fn test_codegen_select_ipk_range_with_order_by_ipk_avoids_sorter() {
let mut stmt = simple_select(
&["a", "b"],
"t",
Some(col_cmp_param("a", AstBinaryOp::Gt, 1)),
);
stmt.order_by = vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: Some(SortDirection::Asc),
nulls: None,
}];
stmt.limit = Some(LimitClause {
limit: placeholder(2),
offset: None,
});
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
has_opcodes(
&prog,
&[
Opcode::Variable,
Opcode::SeekGT,
Opcode::Rowid,
Opcode::Column,
Opcode::ResultRow,
Opcode::DecrJumpZero,
Opcode::Next,
]
),
"ORDER BY on an IPK range should stay on the bounded rowid scan path"
);
assert!(
!ops.iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
)
}),
"ORDER BY on an IPK range must not allocate a sorter temp B-tree"
);
}
#[test]
fn test_codegen_select_ipk_range_with_desc_order_by_ipk_uses_reverse_scan() {
let mut stmt = simple_select(
&["a", "b"],
"t",
Some(col_cmp_param("a", AstBinaryOp::Lt, 1)),
);
stmt.order_by = vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: Some(SortDirection::Desc),
nulls: None,
}];
stmt.limit = Some(LimitClause {
limit: placeholder(2),
offset: None,
});
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
has_opcodes(
&prog,
&[
Opcode::Variable,
Opcode::SeekLT,
Opcode::Rowid,
Opcode::Column,
Opcode::ResultRow,
Opcode::DecrJumpZero,
Opcode::Prev,
]
),
"descending ORDER BY on an IPK range should use the reverse rowid scan path"
);
assert!(
!ops.iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
)
}),
"descending ORDER BY on an IPK range must not allocate a sorter temp B-tree"
);
}
#[test]
fn test_codegen_select_ipk_range_with_wrong_qualifier_errors() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
qualified_col_cmp_param("u", "a", AstBinaryOp::Ge, 1),
qualified_col_cmp_param("u", "a", AstBinaryOp::Lt, 2),
)),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("mismatched qualifier should be a semantic error");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_select_ipk_range_with_non_numeric_text_literal_falls_back() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Gt,
right: Box::new(Expr::Literal(Literal::String("abc".to_owned()), Span::ZERO)),
span: Span::ZERO,
})),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::Rewind),
"non-numeric text rowid/IPK bounds must stay on the generic scan path"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"non-numeric text rowid/IPK bounds must not use the bounded seek fast path"
);
}
#[test]
fn test_codegen_select_ipk_range_with_quoted_numeric_bounds_falls_back() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Ge,
right: Box::new(Expr::Literal(Literal::String("10".to_owned()), Span::ZERO)),
span: Span::ZERO,
}),
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Lt,
right: Box::new(Expr::Literal(Literal::String("20".to_owned()), Span::ZERO)),
span: Span::ZERO,
}),
)),
);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::Rewind),
"quoted numeric rowid/IPK bounds must fall back until affinity-aware seeks exist"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"quoted numeric rowid/IPK bounds must not use the bounded seek fast path"
);
}
#[test]
fn test_codegen_select_ipk_column_uses_rowid_opcode() {
let stmt = simple_select(&["a"], "t", None);
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::Rowid),
"IPK column projection should read rowid"
);
assert!(
!ops.contains(&Opcode::Column),
"single IPK projection should not read record columns"
);
}
#[test]
fn test_codegen_select_shadowed_rowid_column_uses_column_opcode() {
let stmt = simple_select(&["rowid"], "t", None);
let schema = schema_with_visible_rowid_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::Column),
"shadowed rowid column should read from the record payload"
);
assert!(
!ops.contains(&Opcode::Rowid),
"shadowed rowid column must not be compiled as hidden rowid access"
);
}
#[test]
fn test_codegen_select_hidden_rowid_alias_when_rowid_is_shadowed() {
let stmt = simple_select(&["_rowid_"], "t", None);
let schema = schema_with_visible_rowid_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::Rowid),
"unshadowed hidden alias should still use OP_Rowid"
);
assert!(
!ops.contains(&Opcode::Column),
"hidden rowid alias should not read the visible shadowing column"
);
}
#[test]
fn test_codegen_select_hidden_rowid_alias_on_without_rowid_table_errors() {
let stmt = simple_select(&["_rowid_"], "t", None);
let schema = schema_with_without_rowid_ipk();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx).unwrap_err();
assert_eq!(
err,
CodegenError::ColumnNotFound {
table: "t".to_owned(),
column: "_rowid_".to_owned(),
}
);
}
#[test]
fn test_codegen_select_star_uses_rowid_for_ipk_column() {
let stmt = star_select("t");
let schema = schema_with_ipk_alias();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Rewind,
Opcode::Rowid,
Opcode::Column,
Opcode::ResultRow
]
));
}
// === Test 2: INSERT VALUES ===
#[test]
fn test_codegen_insert_values() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenWrite,
Opcode::Variable,
Opcode::Variable,
Opcode::NewRowid,
Opcode::MakeRecord,
Opcode::Insert,
Opcode::Close,
Opcode::Halt,
]
));
// Transaction should be write (p2=1).
let txn = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Transaction)
.unwrap();
assert_eq!(txn.p2, 1);
}
#[test]
fn test_codegen_insert_values_large_integer_literal_uses_int64_opcode() {
let big = 4_102_444_800_000_000_i64;
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![
Expr::Literal(Literal::Integer(big), Span::ZERO),
Expr::Literal(Literal::String("payload".to_owned()), Span::ZERO),
]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Int64
&& matches!(op.p4, P4::Int64(value) if value == big)),
"expected OP_Int64 carrying the full i64 literal in INSERT VALUES codegen"
);
}
#[test]
fn test_codegen_insert_literal_values_preformats_record_blob() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![
Expr::Literal(Literal::Integer(99), Span::ZERO),
Expr::Literal(Literal::String("test".to_owned()), Span::ZERO),
]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
let insert = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("literal INSERT should emit Insert");
assert!(
!ops.contains(&Opcode::Blob),
"literal-only INSERT VALUES should not need a standalone Blob opcode"
);
assert!(
!ops.contains(&Opcode::MakeRecord),
"literal-only INSERT VALUES should preformat the table record"
);
assert!(
matches!(&insert.p4, P4::Blob(record) if !record.is_empty()),
"Insert should carry the preformatted table record directly in P4"
);
}
#[test]
fn test_preformatted_insert_record_stores_null_for_ipk_payload() {
let schema = schema_with_ipk_alias();
let table = &schema[0];
let row_values = vec![
Expr::Literal(Literal::Integer(42), Span::ZERO),
Expr::Literal(Literal::String("payload".to_owned()), Span::ZERO),
];
let record = try_build_preformatted_insert_record(&row_values, table, None)
.expect("literal IPK row should preformat");
let parsed = fsqlite_types::record::parse_record(&record)
.expect("preformatted record should decode");
assert_eq!(
parsed,
vec![
SqliteValue::Null,
SqliteValue::Text(SmallText::new("payload")),
],
"INTEGER PRIMARY KEY aliases must remain rowid keys, not payload values"
);
}
#[test]
fn test_preformatted_insert_record_honors_lowercase_affinity_chars() {
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("a", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let row_values = vec![Expr::Literal(Literal::String("42".to_owned()), Span::ZERO)];
let record = try_build_preformatted_insert_record(&row_values, &schema[0], None)
.expect("literal row should preformat");
let parsed = fsqlite_types::record::parse_record(&record)
.expect("preformatted record should decode");
assert_eq!(
parsed,
vec![SqliteValue::Integer(42)],
"lowercase INTEGER affinity must not silently fall back to BLOB affinity"
);
}
#[test]
fn test_preformatted_insert_record_omitted_default_column_falls_back() {
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'D', false),
ColumnInfo {
name: "b".to_owned(),
affinity: 'D',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: Some("7".to_owned()),
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let row_values = vec![Expr::Literal(Literal::Integer(1), Span::ZERO)];
let mapping = [Some(0), None];
assert!(
try_build_preformatted_insert_record(&row_values, &schema[0], Some(&mapping)).is_none(),
"omitted non-IPK columns must stay on the runtime DEFAULT path"
);
}
#[test]
fn test_preformatted_insert_record_distinguishes_explicit_null_from_omitted_column() {
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'D', false),
ColumnInfo {
name: "b".to_owned(),
affinity: 'D',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: Some("7".to_owned()),
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let explicit_null_values = vec![
Expr::Literal(Literal::Integer(1), Span::ZERO),
Expr::Literal(Literal::Null, Span::ZERO),
];
let omitted_values = vec![Expr::Literal(Literal::Integer(1), Span::ZERO)];
let omitted_mapping = [Some(0), None];
let record = try_build_preformatted_insert_record(&explicit_null_values, &schema[0], None)
.expect("explicit NULL is a fully determined stored value");
let parsed = fsqlite_types::record::parse_record(&record)
.expect("preformatted record should decode");
assert_eq!(parsed, vec![SqliteValue::Integer(1), SqliteValue::Null]);
assert!(
try_build_preformatted_insert_record(
&omitted_values,
&schema[0],
Some(&omitted_mapping),
)
.is_none(),
"omitted columns must not be treated as explicit NULL literals"
);
}
#[test]
fn test_codegen_insert_mixed_preformatable_rows_keeps_runtime_fallback() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![
vec![
Expr::Literal(Literal::Integer(1), Span::ZERO),
Expr::Literal(Literal::String("literal".to_owned()), Span::ZERO),
],
vec![
Expr::BinaryOp {
left: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
},
Expr::Literal(Literal::String("runtime".to_owned()), Span::ZERO),
],
]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let preformatted_inserts = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Insert && matches!(op.p4, P4::Blob(_)))
.count();
let runtime_records = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::MakeRecord)
.count();
assert_eq!(
preformatted_inserts, 1,
"only the literal row should carry a baked record"
);
assert_eq!(
runtime_records, 1,
"the non-literal row must still use runtime MakeRecord"
);
}
#[test]
fn test_emit_limit_expr_large_integer_literal_uses_int64_opcode() {
let big = 4_102_444_800_000_000_i64;
let mut b = ProgramBuilder::new();
let reg = b.alloc_reg();
let expr = Expr::Literal(Literal::Integer(big), Span::ZERO);
emit_limit_expr(&mut b, &expr, reg);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Int64
&& op.p2 == reg
&& matches!(op.p4, P4::Int64(value) if value == big)),
"expected OP_Int64 for large LIMIT literals"
);
}
#[test]
fn test_codegen_insert_values_rejects_mixed_arity_rows() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![
vec![placeholder(1), placeholder(2)],
vec![placeholder(3)],
]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap_err();
// a0e51891e switched ragged VALUES to the verbatim stock message under
// SQLITE_ERROR (CodegenError::SqlError, no "unsupported:" prefix).
assert!(
matches!(err, CodegenError::SqlError(ref msg) if msg.contains("same number of terms")),
"unexpected error: {err:?}"
);
}
#[test]
fn test_emit_comparison_invalid_operator_emits_false() {
let mut b = ProgramBuilder::new();
let reg = b.alloc_reg();
let one = Expr::Literal(Literal::Integer(1), Span::ZERO);
let two = Expr::Literal(Literal::Integer(2), Span::ZERO);
emit_comparison(&mut b, &one, AstBinaryOp::Add, &two, reg, None);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::Integer && op.p1 == 0 && op.p2 == reg),
"expected fallback false assignment for invalid comparison op"
);
}
#[test]
fn test_emit_is_comparison_invalid_operator_emits_false() {
let mut b = ProgramBuilder::new();
let reg = b.alloc_reg();
let one = Expr::Literal(Literal::Integer(1), Span::ZERO);
let two = Expr::Literal(Literal::Integer(2), Span::ZERO);
emit_is_comparison(&mut b, &one, AstBinaryOp::Eq, &two, reg, None);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::Integer && op.p1 == 0 && op.p2 == reg),
"expected fallback false assignment for invalid IS/IS NOT op"
);
}
// === Test: INSERT ... SELECT ===
#[test]
#[allow(clippy::too_many_lines)]
fn test_codegen_insert_select() {
// Schema with two tables: source "s" and target "t".
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo {
name: "a".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "b".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo {
name: "x".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "y".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
];
// INSERT INTO t SELECT * FROM s
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should contain: Init, Transaction(write), OpenWrite(target),
// OpenRead(source), Rewind, Column reads, NewRowid, MakeRecord,
// Insert, Next, Close(source), Close(target), Halt.
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenWrite,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::Column,
Opcode::NewRowid,
Opcode::MakeRecord,
Opcode::Insert,
Opcode::Next,
Opcode::Close,
Opcode::Close,
Opcode::Halt,
]
));
// Transaction should be write (p2=1).
let txn = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Transaction)
.unwrap();
assert_eq!(txn.p2, 1);
// OpenWrite should target table "t" (root_page=2).
let open_write = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenWrite)
.unwrap();
assert_eq!(open_write.p2, 2);
// OpenRead should target table "s" (root_page=3).
let open_read = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenRead)
.unwrap();
assert_eq!(open_read.p2, 3);
}
#[test]
fn test_codegen_insert_select_without_from_emits_single_insert_path() {
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Literal(Literal::Integer(7), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Literal(Literal::String("seven".to_owned()), Span::ZERO),
alias: None,
},
],
from: None,
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(ops.contains(&Opcode::Insert));
assert!(ops.contains(&Opcode::Integer));
assert!(ops.contains(&Opcode::String8));
assert!(!ops.contains(&Opcode::OpenRead));
assert!(!ops.contains(&Opcode::Rewind));
assert!(!ops.contains(&Opcode::Next));
}
#[test]
fn test_codegen_insert_select_without_from_where_emits_filter_jump() {
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("a", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Literal(Literal::Integer(1), Span::ZERO),
alias: None,
}],
from: None,
where_clause: Some(Box::new(Expr::Literal(Literal::False, Span::ZERO))),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IfNot),
"expected WHERE filter in no-FROM INSERT ... SELECT path"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_codegen_insert_select_propagates_or_conflict_to_insert_p5() {
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("x", 'd', false),
ColumnInfo::basic("y", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
];
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: Some(fsqlite_ast::ConflictAction::Ignore),
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let insert = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("expected Insert opcode");
assert_eq!(insert.p5, OE_IGNORE);
}
#[test]
#[allow(clippy::too_many_lines)]
fn test_codegen_insert_select_emits_index_inserts() {
// Target has one secondary index; source has none.
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'C', false),
],
indexes: vec![IndexSchema {
name: "idx_t_a".to_owned(),
root_page: 4,
columns: vec!["a".to_owned()],
key_expressions: vec!["a".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("x", 'd', false),
ColumnInfo::basic("y", 'C', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
];
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenWrite && op.p2 == 4),
"expected OpenWrite for target secondary index root page"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxInsert),
"INSERT ... SELECT should maintain target indexes via IdxInsert"
);
}
// === Test: INSERT ... SELECT with specific columns ===
#[test]
#[allow(clippy::too_many_lines)]
fn test_codegen_insert_select_with_explicit_columns_reorders_projection() {
// Schema with source "s" having 3 columns, target "t" with 2.
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo {
name: "a".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "b".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo {
name: "x".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo {
name: "y".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
ColumnInfo::basic("z", 'e', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
];
// INSERT INTO t(b, a) SELECT x, y FROM s
let inner_select = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("x"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("y"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["b".to_owned(), "a".to_owned()],
source: InsertSource::Select(Box::new(inner_select)),
upsert: vec![],
returning: vec![],
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have exactly 2 Column reads (x and y), not 3.
let column_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Column)
.count();
assert_eq!(column_count, 2);
let copy_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Copy)
.count();
assert!(
copy_count >= 2,
"explicit target-column mapping should reorder both projected values"
);
}
#[test]
fn test_codegen_empty_values_select_emits_no_rows() -> Result<(), String> {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Values(vec![].into()),
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
let ops = opcode_sequence(&prog);
if ops.contains(&Opcode::ResultRow) {
return Err("empty VALUES should not emit result rows".to_owned());
}
if !has_opcodes(&prog, &[Opcode::Init, Opcode::Transaction, Opcode::Halt]) {
return Err(format!(
"empty VALUES should emit a valid no-row program, got {ops:?}"
));
}
Ok(())
}
// === Test: SELECT DISTINCT full scan ===
#[test]
fn test_codegen_select_distinct_full_scan() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::Distinct,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Unordered DISTINCT preserves source order and probes a membership
// index before emitting each first-occurrence tuple.
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenAutoindex,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::Column,
Opcode::MakeRecord,
Opcode::Found,
Opcode::IdxInsert,
Opcode::ResultRow,
Opcode::Next,
Opcode::Close,
Opcode::Close,
]
));
}
#[test]
fn test_codegen_select_distinct_star_preserves_flattened_collations() {
let mut schema = test_schema();
schema[0].columns[1].collation = Some("NOCASE".to_owned());
for sql in ["SELECT DISTINCT * FROM t", "SELECT DISTINCT t.* FROM t"] {
let stmt = select_sql(sql);
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let distinct_open = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenAutoindex)
.expect("unordered DISTINCT should open a membership index");
assert_eq!(distinct_open.p2, 2, "{sql}");
assert_eq!(
distinct_open.p4,
P4::Str("BINARY,NOCASE".to_owned()),
"{sql}: expanded output slots must retain positional column collations"
);
}
}
#[test]
fn test_codegen_select_distinct_full_scan_offset_after_dedup() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::Distinct,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(5), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(1), Span::ZERO)),
}),
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let found_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::Found)
.expect("missing DISTINCT membership probe");
let ifpos_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::IfPos)
.expect("missing OFFSET IfPos opcode");
assert!(
found_pos < ifpos_pos,
"DISTINCT dedup must run before OFFSET filtering"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"unordered DISTINCT must not sort before LIMIT/OFFSET"
);
}
// === Test: SELECT DISTINCT with ORDER BY ===
#[test]
fn test_codegen_select_distinct_with_order_by() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::Distinct,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: None,
nulls: None,
}],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// ORDER BY + DISTINCT uses a sorter for ordering and a separate
// output-tuple membership index for deduplication.
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::SorterOpen,
Opcode::OpenAutoindex,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::MakeRecord,
Opcode::Found,
Opcode::IdxInsert,
Opcode::SorterInsert,
Opcode::Next,
Opcode::SorterSort,
Opcode::SorterData,
Opcode::ResultRow,
Opcode::SorterNext,
]
));
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterCompare),
"ordered DISTINCT must not compare an output tuple against the ORDER BY key prefix"
);
}
#[test]
fn test_codegen_select_distinct_with_order_by_offset_after_dedup() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::Distinct,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: Some(SortDirection::Asc),
nulls: None,
}],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(5), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(2), Span::ZERO)),
}),
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let found_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::Found)
.expect("missing DISTINCT membership probe");
let ifpos_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::IfPos)
.expect("missing OFFSET IfPos opcode");
assert!(
found_pos < ifpos_pos,
"DISTINCT dedup must run before OFFSET filtering"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxInsert),
"a novel output tuple must be added to the DISTINCT membership index"
);
let distinct_insert_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::IdxInsert)
.expect("missing DISTINCT membership insert");
let top_n_preflight_pos = prog
.ops()
.iter()
.position(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
})
.expect("bounded ordered DISTINCT should preflight sorter admission");
assert!(
found_pos < distinct_insert_pos && distinct_insert_pos < top_n_preflight_pos,
"DISTINCT membership must precede bounded-sorter admission so rejected first \
representatives still suppress later duplicates"
);
assert!(
!prog.ops().iter().any(|op| {
op.opcode == Opcode::SorterCompare && op.p5 != SORTER_COMPARE_TOP_N_PREFLIGHT
}),
"ordered DISTINCT must not use the old output-record-versus-sort-key comparison"
);
}
#[test]
fn test_codegen_ordered_distinct_membership_preserves_output_collation_slots() {
let mut schema = test_schema();
schema[0].columns[1].collation = Some("NOCASE".to_owned());
let stmt = select_sql("SELECT DISTINCT a, b FROM t ORDER BY b");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let membership_open = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenAutoindex)
.expect("ordered DISTINCT should open an output membership index");
assert_eq!(membership_open.p2, 2);
assert_eq!(
membership_open.p4,
P4::Str("BINARY,NOCASE".to_owned()),
"default-collation slots must remain explicit so later named collations keep their positions"
);
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::Found && op.p1 == membership_open.p1 && op.p3 > 0
}),
"the flattened output record must probe the membership index"
);
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::IdxInsert && op.p1 == membership_open.p1 && op.p2 > 0
}),
"novel flattened output records must enter the membership index"
);
}
#[test]
fn test_codegen_ordered_distinct_dedups_before_independent_order_expression() {
let schema = test_schema();
let stmt = select_sql("SELECT DISTINCT out(b) AS x FROM t ORDER BY key(a), x DESC");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let output_pos = ops
.iter()
.position(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.expect("DISTINCT output should evaluate out()");
let found_pos = ops
.iter()
.position(|op| op.opcode == Opcode::Found)
.expect("DISTINCT output should be membership-tested");
let independent_order_pos = ops
.iter()
.position(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "KEY")
})
.expect("independent ORDER BY expression should evaluate key()");
assert!(
output_pos < found_pos && found_pos < independent_order_pos,
"duplicate output rows must skip evaluation of independent ORDER BY expressions"
);
assert_eq!(
ops.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count(),
1,
"the exact ORDER alias should reuse the already-deduplicated output value"
);
}
// === Test 3: UPDATE by rowid ===
#[test]
fn test_codegen_update_by_rowid() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Rowid equality should use the direct SeekRowid fast path instead of
// scanning the whole table.
let ops = prog.ops();
let seek_pos = ops
.iter()
.position(|op| op.opcode == Opcode::SeekRowid)
.expect("UPDATE by rowid must probe directly with SeekRowid");
let delete_pos = ops
.iter()
.position(|op| op.opcode == Opcode::Delete)
.expect("UPDATE must delete the old row before reinserting");
let delete = ops
.iter()
.find(|op| op.opcode == Opcode::Delete)
.expect("Delete opcode should exist");
let insert_pos = ops
.iter()
.position(|op| op.opcode == Opcode::Insert)
.expect("UPDATE must reinsert the rewritten row");
let insert = ops
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("Insert opcode should exist");
assert!(
seek_pos < delete_pos && delete_pos < insert_pos,
"UPDATE by rowid should seek first, then delete, then reinsert"
);
assert!(
ops.iter()
.skip(seek_pos)
.any(|op| op.opcode == Opcode::Column),
"UPDATE should read the non-IPK column from the current row"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::RowSetAdd | Opcode::RowSetRead)),
"single-rowid UPDATE should not materialize a one-row RowSet"
);
assert_eq!(
delete.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE delete must carry OPFLAG_ISUPDATE so conflict restore can recover the old row"
);
assert_eq!(
insert.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE insert must carry OPFLAG_ISUPDATE so runtime last_insert_rowid() handling stays SQLite-compatible"
);
// MakeRecord should have 2 columns (the full record).
let mr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::MakeRecord)
.unwrap();
assert_eq!(mr.p2, 2); // ALL columns, not just the changed one.
}
#[test]
fn test_codegen_update_ipk_assignment_updates_rowid() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("a".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let mut schema = test_schema();
schema[0].columns[0].is_ipk = true;
let ctx = CodegenContext {
concurrent_mode: true,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops: Vec<Opcode> = prog.ops().iter().map(|op| op.opcode).collect();
assert!(
ops.contains(&Opcode::Delete),
"UPDATE must delete old row before reinsert"
);
assert!(
ops.contains(&Opcode::IsNull) && ops.contains(&Opcode::NewRowid),
"IPK update should handle NULL rowid by generating NewRowid"
);
let delete_pos = ops
.iter()
.position(|&op| op == Opcode::Delete)
.expect("Delete opcode should exist");
let delete = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Delete)
.expect("Delete opcode should exist");
let insert = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("Insert opcode should exist");
let insert_pos = ops
.iter()
.position(|&op| op == Opcode::Insert)
.expect("Insert opcode should exist");
assert!(
delete_pos < insert_pos,
"Delete must execute before Insert in UPDATE rewrite"
);
assert_eq!(
delete.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE delete must carry OPFLAG_ISUPDATE so conflict restore can recover the old row"
);
assert_eq!(
insert.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE insert must carry OPFLAG_ISUPDATE so runtime last_insert_rowid() handling stays SQLite-compatible"
);
}
// === Test 4: DELETE by rowid ===
#[test]
fn test_codegen_delete_by_rowid() {
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Rowid equality should use a direct probe in pass 1, then delete via
// the collected rowset in pass 2.
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenWrite,
Opcode::Variable, // target rowid
Opcode::SeekRowid, // direct probe
Opcode::RowSetAdd, // into rowset
Opcode::RowSetRead, // pass 2: iterate collected rowids
Opcode::SeekRowid, // seek to rowid
Opcode::Delete, // delete row
Opcode::Close,
Opcode::Halt,
]
));
}
#[test]
fn test_codegen_update_with_index_emits_keyed_idxdelete() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let idx_delete = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::IdxDelete)
.expect("expected IdxDelete for indexed UPDATE");
assert!(
idx_delete.p3 > 0,
"IdxDelete must carry key register count (p3 > 0) so engine seeks by key"
);
}
#[test]
fn test_codegen_update_skips_unchanged_simple_index_maintenance() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("a".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::IdxDelete | Opcode::IdxInsert)),
"UPDATE of non-indexed columns should leave unchanged simple indexes in place"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Delete | Opcode::Insert)),
"table row rewrite should remain unchanged"
);
}
#[test]
fn test_codegen_update_non_rowid_predicate_uses_two_pass_rowset() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("a".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Rewind,
Opcode::RowSetAdd,
Opcode::Next,
Opcode::RowSetRead,
Opcode::SeekRowid,
Opcode::Delete,
Opcode::Insert,
]
));
let rowset_add_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::RowSetAdd)
.expect("RowSetAdd should be emitted before mutation");
let delete_pos = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::Delete)
.expect("Delete opcode should exist");
let delete = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Delete)
.expect("Delete opcode should exist");
let insert = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("Insert opcode should exist");
assert!(
rowset_add_pos < delete_pos,
"UPDATE must collect rowids before deleting rows from a scan cursor"
);
assert_eq!(
delete.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE delete must carry OPFLAG_ISUPDATE so conflict restore can recover the old row"
);
assert_eq!(
insert.p5 & OPFLAG_ISUPDATE,
OPFLAG_ISUPDATE,
"UPDATE insert must carry OPFLAG_ISUPDATE so runtime last_insert_rowid() handling stays SQLite-compatible"
);
}
#[test]
fn test_codegen_delete_with_index_emits_keyed_idxdelete() {
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let idx_delete = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::IdxDelete)
.expect("expected IdxDelete for indexed DELETE");
assert!(
idx_delete.p3 > 0,
"IdxDelete must carry key register count (p3 > 0) so engine seeks by key"
);
}
// === Test 5: Label resolution ===
#[test]
fn test_codegen_label_resolution() {
let stmt = simple_select(&["a"], "t", Some(rowid_eq_param()));
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// All p2 fields that are jumps should have valid addresses (>= 0).
for op in prog.ops() {
if op.opcode.is_jump() {
assert!(
op.p2 >= 0,
"unresolved jump at {:?}: p2 = {}",
op.opcode,
op.p2
);
assert!(
usize::try_from(op.p2).unwrap() <= prog.len(),
"jump target out of range at {:?}: p2 = {} (prog len = {})",
op.opcode,
op.p2,
prog.len()
);
}
}
}
// === Test 6: Register allocation ===
#[test]
fn test_codegen_register_allocation() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// All register references (p1, p2, p3 where applicable) should be
// within the allocated range.
let max_reg = prog.register_count();
assert!(max_reg > 0);
// Variable instructions: p2 is the target register.
for op in prog.ops() {
if op.opcode == Opcode::Variable {
assert!(
op.p2 >= 1 && op.p2 <= max_reg,
"Variable register out of range: p2 = {}, max = {}",
op.p2,
max_reg
);
}
}
}
// === Test 7: Concurrent mode NewRowid ===
#[test]
fn test_codegen_concurrent_newrowid() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext {
concurrent_mode: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// In concurrent mode, NewRowid p3 should be non-zero.
let nr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::NewRowid)
.unwrap();
assert_ne!(
nr.p3, 0,
"NewRowid p3 should be non-zero in concurrent mode"
);
// In non-concurrent mode, p3 should be 0.
let ctx_normal = CodegenContext::default();
let mut b2 = ProgramBuilder::new();
codegen_insert(&mut b2, &stmt, &schema, &ctx_normal).unwrap();
let prog2 = b2.finish().unwrap();
let nr2 = prog2
.ops()
.iter()
.find(|op| op.opcode == Opcode::NewRowid)
.unwrap();
assert_eq!(nr2.p3, 0, "NewRowid p3 should be 0 in normal mode");
}
#[test]
fn test_codegen_concurrent_newrowid_default_values() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::DefaultValues,
upsert: vec![],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext {
concurrent_mode: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let nr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::NewRowid)
.unwrap();
assert_ne!(
nr.p3, 0,
"NewRowid p3 should be non-zero in concurrent mode for DEFAULT VALUES"
);
// In non-concurrent mode, p3 should be 0 for DEFAULT VALUES as well.
let ctx_normal = CodegenContext::default();
let mut b2 = ProgramBuilder::new();
codegen_insert(&mut b2, &stmt, &schema, &ctx_normal).unwrap();
let prog2 = b2.finish().unwrap();
let nr2 = prog2
.ops()
.iter()
.find(|op| op.opcode == Opcode::NewRowid)
.unwrap();
assert_eq!(
nr2.p3, 0,
"NewRowid p3 should be 0 in normal mode for DEFAULT VALUES"
);
}
// === Test 8: SELECT full scan ===
#[test]
fn test_codegen_select_full_scan() {
let stmt = star_select("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::Column,
Opcode::ResultRow,
Opcode::Next,
Opcode::Close,
Opcode::Halt,
]
));
// ResultRow should cover 2 columns (a and b).
let rr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::ResultRow)
.unwrap();
assert_eq!(rr.p2, 2);
}
#[test]
fn test_codegen_select_honors_planner_full_scan_directive_over_index_probe() {
let stmt = simple_select(&["a"], "t", Some(col_cmp_param("b", AstBinaryOp::Eq, 1)));
let schema = test_schema_with_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-full-scan".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: None,
index_key_label: None,
index_key_is_expression: false,
index_equality_target: None,
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::FullTableScan,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"planner full-scan directive should bypass the index probe fast path"
);
assert!(
!ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")
}),
"planner full-scan directive should not open the candidate index"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"planner full-scan directive should not emit an index seek"
);
}
#[test]
fn test_codegen_select_bypasses_stale_planner_rowid_directive() {
let stmt = simple_select(&["a"], "t", Some(col_cmp_param("b", AstBinaryOp::Eq, 1)));
let schema = test_schema_with_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-stale-rowid".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: None,
index_key_label: None,
index_key_is_expression: false,
index_equality_target: None,
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::RowidLookup,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")
}),
"stale rowid directive should be bypassed so heuristic index lowering can still run"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"stale rowid directive should fall back to the ordinary index-equality fast path"
);
}
#[test]
fn test_codegen_select_honors_covering_planner_index_directive_without_table_lookup() {
let stmt = simple_select(&["b"], "t", Some(col_cmp_param("b", AstBinaryOp::Eq, 1)));
let schema = test_schema_with_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-covering-equality".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_b".to_owned()),
index_key_label: Some("b".to_owned()),
index_key_is_expression: false,
index_equality_target: None,
index_range_target: None,
covering: true,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let seek_ge_idx = ops
.iter()
.position(|op| op.opcode == Opcode::SeekGE)
.expect("covering equality fast path should probe the chosen index");
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")
}),
"covering planner directive should still open the chosen index"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"covering planner directive should avoid table lookups in equality scans"
);
assert!(
!ops[..seek_ge_idx].iter().any(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "t")
}),
"covering equality fast path should defer opening the table cursor until fallback"
);
assert!(
ops[seek_ge_idx + 1..].iter().any(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "t")
}),
"covering equality fast path should retain a lazy table-open fallback"
);
}
#[test]
fn test_codegen_select_honors_expression_planner_index_equality_directive() {
let stmt = simple_select(&["name"], "t", Some(lower_name_eq_param(1)));
let schema = test_schema_with_expression_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-expression-equality".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("lower(name)".to_owned()),
index_key_is_expression: true,
index_equality_target: Some(placeholder(1)),
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_lower_name")
}),
"expression planner directive should open the chosen expression index"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"expression equality directive should lower to an index probe"
);
}
#[test]
fn test_codegen_composite_expression_equality_uses_true_prefix_probe() {
let stmt = simple_select(&["name"], "t", Some(lower_name_eq_param(1)));
let mut schema = test_schema_with_expression_index();
schema[0].indexes[0].key_expressions.push("a".to_owned());
schema[0].indexes[0].key_sort_directions = vec![SortDirection::Asc, SortDirection::Desc];
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-composite-expression-equality".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("lower(name)".to_owned()),
index_key_is_expression: true,
index_equality_target: Some(placeholder(1)),
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let seek_idx = ops
.iter()
.position(|op| op.opcode == Opcode::SeekGE)
.expect("composite expression equality should seek its index");
let probe = ops[..seek_idx]
.iter()
.rev()
.find(|op| op.opcode == Opcode::MakeRecord)
.expect("index seek must build a probe record");
assert_eq!(
probe.p2, 1,
"unconstrained DESC trailing terms must not be padded with NULL"
);
}
#[test]
fn test_codegen_select_honors_partial_expression_index_equality_directive() {
let stmt = simple_select_as(
&["name"],
"t",
"x",
Some(Box::new(expr_sql("lower(x.Name) = ?1 AND x.A = 1"))),
);
let mut schema = test_schema_with_expression_index();
schema[0].indexes[0].key_expressions[0] = "LOWER(NAME)".to_owned();
schema[0].indexes[0].where_clause = Some("A = 1".to_owned());
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-partial-expression-equality".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("LOWER(NAME)".to_owned()),
index_key_is_expression: true,
index_equality_target: Some(placeholder(1)),
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_lower_name")
}),
"a residual guaranteed by the partial index must not make the VDBE reject the directive"
);
assert!(prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE));
}
#[test]
fn test_codegen_select_honors_expression_planner_index_range_directive() {
let stmt = simple_select(&["name"], "t", Some(lower_name_range_params(1, 2)));
let schema = test_schema_with_expression_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-expression-range".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("lower(name)".to_owned()),
index_key_is_expression: true,
index_equality_target: None,
index_range_target: Some(PlannerIndexRangeTarget {
lower: Some(PlannerIndexRangeBound {
expr: placeholder(1),
inclusive: true,
}),
upper: Some(PlannerIndexRangeBound {
expr: placeholder(2),
inclusive: false,
}),
}),
covering: false,
access_kind: PlannerSelectAccessKind::IndexRange,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_lower_name")
}),
"expression range directive should open the chosen expression index"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"expression range directive should lower to an index range probe"
);
}
#[test]
fn test_codegen_select_honors_partial_expression_index_range_directive() {
let stmt = simple_select_as(
&["name"],
"t",
"x",
Some(Box::new(expr_sql(
"lower(x.Name) >= ?1 AND lower(x.name) < ?2 AND x.A = 1",
))),
);
let mut schema = test_schema_with_expression_index();
schema[0].indexes[0].key_expressions[0] = "LOWER(NAME)".to_owned();
schema[0].indexes[0].where_clause = Some("A = 1".to_owned());
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-partial-expression-range".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("LOWER(NAME)".to_owned()),
index_key_is_expression: true,
index_equality_target: None,
index_range_target: Some(PlannerIndexRangeTarget {
lower: Some(PlannerIndexRangeBound {
expr: placeholder(1),
inclusive: true,
}),
upper: Some(PlannerIndexRangeBound {
expr: placeholder(2),
inclusive: false,
}),
}),
covering: false,
access_kind: PlannerSelectAccessKind::IndexRange,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_lower_name")
}),
"partial-index range proof must survive the independent VDBE verifier"
);
assert!(prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE));
}
#[test]
fn test_codegen_select_bypasses_expression_planner_index_directive_with_residual_filter() {
let stmt = simple_select(
&["name"],
"t",
Some(and_expr(
lower_name_eq_param(1),
col_cmp_param("a", AstBinaryOp::Eq, 2),
)),
);
let schema = test_schema_with_expression_index();
let ctx = CodegenContext {
planner_select_directive: Some(SelectPlannerDirective {
plan_id: "plan-expression-residual".to_owned(),
plan_generation: 1,
planner_surface: "single_table_access_path_v1".to_owned(),
table_name: "t".to_owned(),
index_name: Some("idx_t_lower_name".to_owned()),
index_key_label: Some("lower(name)".to_owned()),
index_key_is_expression: true,
index_equality_target: Some(placeholder(1)),
index_range_target: None,
covering: false,
access_kind: PlannerSelectAccessKind::IndexEquality,
}),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
!ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_lower_name")
}),
"residual predicates should bypass the expression-index directive fast path"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"bypassed expression directive should fall back to a scan"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"bypassed expression directive must not emit an index seek"
);
}
#[test]
fn test_codegen_select_non_column_expr_with_from_accepted() {
// Non-column expressions in SELECT list with FROM are now supported
// via ScanCtx-aware emit_expr in emit_column_reads.
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::BinaryOp {
left: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(2), Span::ZERO)),
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx)
.expect("non-column expression in SELECT list should succeed");
let prog = b.finish().unwrap();
// Should contain Add opcode for the 1 + 2 expression.
assert!(
has_opcodes(
&prog,
&[Opcode::Init, Opcode::OpenRead, Opcode::Rewind, Opcode::Add]
),
"expected Add opcode for expression evaluation"
);
}
#[test]
fn test_codegen_select_substr_prefix_column_uses_direct_opcode() {
let stmt = select_sql("SELECT substr(b, 1, 3) FROM t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::ColumnSubstrPrefix),
"literal prefix substr(column, 1, N) should avoid full column materialization"
);
assert!(
!ops.contains(&Opcode::PureFunc),
"direct prefix substr should not emit scalar function dispatch"
);
}
#[test]
fn test_codegen_select_substr_prefix_column_executes() {
let stmt = select_sql("SELECT substr(name, 1, 4), substring(name, 1, 99) FROM bench");
let results = execute_codegen_select_with_storage_cursor(
&stmt,
&test_small_bench_schema(),
seed_small_bench_db(3),
);
assert_eq!(
results,
vec![
vec![
SqliteValue::Text("name".into()),
SqliteValue::Text("name0".into()),
],
vec![
SqliteValue::Text("name".into()),
SqliteValue::Text("name1".into()),
],
vec![
SqliteValue::Text("name".into()),
SqliteValue::Text("name2".into()),
],
]
);
}
#[test]
fn test_codegen_select_substr_prefix_column_preserves_unicode_and_blob_edges() {
let mut db = MemDatabase::new();
db.create_table_at(2, 3);
let table = db.get_table_mut(2).expect("bench table should exist");
table.insert_row(
1,
vec![
SqliteValue::Integer(1),
SqliteValue::Text("éclair".into()),
SqliteValue::Float(1.0),
],
);
table.insert_row(
2,
vec![
SqliteValue::Integer(2),
SqliteValue::Blob(Arc::from(&b"abcdef"[..])),
SqliteValue::Float(2.0),
],
);
let stmt = select_sql("SELECT substr(name, 1, 2) FROM bench");
let results =
execute_codegen_select_with_storage_cursor(&stmt, &test_small_bench_schema(), db);
assert_eq!(
results,
vec![
vec![SqliteValue::Text("éc".into())],
vec![SqliteValue::Blob(Arc::from(&b"ab"[..]))],
]
);
}
#[test]
fn test_codegen_select_octet_length_column_uses_record_metadata_opcode() {
let stmt = select_sql("SELECT octet_length(name) FROM bench");
let schema = test_small_bench_schema();
let ctx = CodegenContext::default();
let mut builder = ProgramBuilder::new();
codegen_select(&mut builder, &stmt, &schema, &ctx).unwrap();
let program = builder.finish().unwrap();
let ops = opcode_sequence(&program);
assert!(
ops.contains(&Opcode::ColumnOctetLength),
"octet_length(column) must inspect record metadata before source materialization"
);
assert!(
!ops.contains(&Opcode::PureFunc),
"direct octet length should not decode the source for scalar dispatch"
);
let mut db = MemDatabase::new();
db.create_table_at(2, 3);
let table = db.get_table_mut(2).expect("bench table should exist");
for (rowid, value) in [
SqliteValue::Text("éclair".into()),
SqliteValue::Blob(Arc::from(&b"abcdef"[..])),
SqliteValue::Integer(123),
SqliteValue::Null,
]
.into_iter()
.enumerate()
{
table.insert_row(
i64::try_from(rowid + 1).unwrap(),
vec![
SqliteValue::Integer(i64::try_from(rowid + 1).unwrap()),
value,
SqliteValue::Float(1.0),
],
);
}
let results = execute_codegen_select_with_storage_cursor(&stmt, &schema, db);
assert_eq!(
results,
vec![
vec![SqliteValue::Integer(7)],
vec![SqliteValue::Integer(6)],
vec![SqliteValue::Integer(3)],
vec![SqliteValue::Null],
]
);
}
#[test]
fn test_codegen_octet_length_virtual_generated_column_recomputes_value() {
let stmt = select_sql("SELECT octet_length(c) FROM t");
let schema = test_schema_with_virtual_generated();
let ctx = CodegenContext::default();
let mut builder = ProgramBuilder::new();
codegen_select(&mut builder, &stmt, &schema, &ctx).unwrap();
let program = builder.finish().unwrap();
assert!(
!opcode_sequence(&program).contains(&Opcode::ColumnOctetLength),
"a VIRTUAL generated column must be recomputed before octet_length"
);
let mut db = MemDatabase::new();
db.create_table_at(2, 3);
db.get_table_mut(2).expect("table should exist").insert_row(
1,
vec![
SqliteValue::Integer(7),
SqliteValue::Integer(0),
SqliteValue::Null,
],
);
let results = execute_codegen_select_with_storage_cursor(&stmt, &schema, db);
assert_eq!(results, vec![vec![SqliteValue::Integer(2)]]);
}
#[test]
fn test_codegen_select_table_star_wrong_table_rejected() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::TableStar(QualifiedName::bare("u"))],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err =
codegen_select(&mut b, &stmt, &schema, &ctx).expect_err("unknown table qualifier");
assert_eq!(err, CodegenError::TableNotFound("u".to_owned()));
}
#[test]
fn test_codegen_select_with_clause_fails_closed() {
let stmt = select_sql("WITH picked AS (SELECT b FROM s) SELECT a FROM t");
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("WITH lowering must not silently bypass CTE semantics");
match err {
CodegenError::Unsupported(message) => assert!(
message.contains("WITH clauses require connection-level CTE lowering"),
"unexpected unsupported message: {message}"
),
other => assert!(
false,
"expected explicit unsupported WITH boundary, got {other:?}"
),
}
assert_eq!(
b.current_addr(),
0,
"fail-closed WITH handling should not emit partial bytecode"
);
}
// === Test 9: SELECT with indexed predicate ===
#[test]
fn test_codegen_select_with_index() {
let stmt = simple_select(&["a"], "t", Some(col_eq_param("b", 1)));
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Indexed equality should probe the index, anchor on the first
// duplicate using [param, i64::MIN], and iterate the duplicate run.
let open_reads = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::OpenRead)
.count();
assert_eq!(
open_reads, 2,
"indexed equality should open both table and index cursors"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"expected index cursor open for indexed equality probe"
);
let int64 = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Int64)
.expect("Int64 should load i64::MIN for duplicate-range seek lower bound");
assert_eq!(int64.p4, P4::Int64(i64::MIN));
let make_record = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::MakeRecord)
.expect("MakeRecord should build the composite probe key");
assert_eq!(
make_record.p1 + 1,
int64.p2,
"MakeRecord should consume [param_reg, min_rowid_reg]"
);
let seek_ge = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SeekGE)
.expect("SeekGE should be emitted for index probe");
assert_eq!(
seek_ge.p3, make_record.p3,
"SeekGE must read probe key from MakeRecord destination register"
);
let is_null_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::IsNull)
.count();
assert!(
is_null_count >= 1,
"indexed equality should guard NULL probe"
);
let seek_rowid = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SeekRowid)
.expect("SeekRowid should follow IdxRowid");
assert_ne!(
seek_rowid.p2, 0,
"SeekRowid miss target must not jump to pc=0"
);
let next = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Next)
.expect("index equality path must iterate duplicates");
assert_eq!(next.p1, 1, "Next should advance the index cursor");
let boundary_column = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Column && op.p1 == 1 && op.p2 == 0)
.expect("single-column duplicate-run boundary should read the index key");
let boundary_ne = prog
.ops()
.iter()
.find(|op| {
op.opcode == Opcode::Ne && op.p1 == make_record.p1 && op.p3 == boundary_column.p3
})
.expect("single-column duplicate-run boundary should compare against the probe value");
let if_addr = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::If)
.expect("normal duplicate-run exit should branch through a match gate");
assert_eq!(
usize::try_from(boundary_ne.p2).unwrap(),
if_addr,
"duplicate-run boundary must not jump directly into the full-scan fallback"
);
assert_eq!(
boundary_ne.p5 & 0x10,
0x10,
"duplicate-run boundary should jump when the index cursor reaches EOF"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::IdxGT),
"single-column equality should not need the generic record-prefix boundary"
);
}
fn test_schema_with_composite_prefix_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
ColumnInfo::basic("idx", 'D', false),
ColumnInfo::basic("content", 'B', false),
],
indexes: vec![IndexSchema {
name: "sqlite_autoindex_messages_1".to_owned(),
root_page: 3,
columns: vec!["conversation_id".to_owned(), "idx".to_owned()],
key_expressions: vec!["conversation_id".to_owned(), "idx".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_three_column_composite_index() -> Vec<TableSchema> {
vec![TableSchema {
name: "events".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
ColumnInfo::basic("stream_id", 'D', false),
ColumnInfo::basic("sequence", 'D', false),
],
indexes: vec![IndexSchema {
name: "sqlite_autoindex_events_1".to_owned(),
root_page: 3,
columns: vec![
"tenant_id".to_owned(),
"stream_id".to_owned(),
"sequence".to_owned(),
],
key_expressions: vec![
"tenant_id".to_owned(),
"stream_id".to_owned(),
"sequence".to_owned(),
],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn test_schema_with_collated_composite_prefix_index() -> Vec<TableSchema> {
let mut tenant = ColumnInfo::basic("tenant", 'B', false);
tenant.collation = Some("NOCASE".to_owned());
vec![TableSchema {
name: "collated_events".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
tenant,
ColumnInfo::basic("sequence", 'D', false),
],
indexes: vec![IndexSchema {
name: "sqlite_autoindex_collated_events_1".to_owned(),
root_page: 3,
columns: vec!["tenant".to_owned(), "sequence".to_owned()],
key_expressions: vec!["tenant".to_owned(), "sequence".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![Some("NOCASE".to_owned()), None],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
#[test]
fn test_codegen_select_with_composite_index_prefix_equality_anchors_full_key() {
let schema = test_schema_with_composite_prefix_index();
let table = &schema[0];
let idx_schema = &table.indexes[0];
let columns = vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("idx"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("content"), Span::ZERO),
alias: None,
},
];
let where_clause = col_eq_param("conversation_id", 1);
let mut b = ProgramBuilder::new();
let out_regs = b.alloc_regs(columns.len() as i32);
let done_label = b.emit_label();
let end_label = b.emit_label();
codegen_select_index_equality_scan(
&mut b,
0,
table,
None,
&schema,
&columns,
Some(&where_clause),
None,
out_regs,
columns.len() as i32,
done_label,
end_label,
idx_schema,
&placeholder(1),
false,
false,
)
.unwrap();
b.resolve_label(done_label);
b.resolve_label(end_label);
let prog = b.finish().unwrap();
let ops = prog.ops();
let seek_ge = ops
.iter()
.find(|op| op.opcode == Opcode::SeekGE)
.expect("composite equality scan should probe the autoindex");
let make_record = ops
.iter()
.find(|op| op.opcode == Opcode::MakeRecord && op.p3 == seek_ge.p3)
.expect("SeekGE should consume a dedicated composite probe record");
assert_eq!(
seek_ge.p3, make_record.p3,
"SeekGE must read the composite probe key from MakeRecord"
);
// DESC-safety (bd-2fong seek-record rework): a COMPOSITE index is
// positioned with a TRUE one-field prefix record — just the leading
// `conversation_id` term — NOT the leading term NULL-padded across the
// trailing indexed columns plus a rowid floor. Padding trailing terms
// with NULL is not a valid block floor when any trailing term is DESC:
// SeekGE would start in the trailing-NULL region and silently skip
// preceding non-NULL entries. The equality-prefix duplicate run is
// instead bounded by the IdxGT recheck (see the sibling ordered-scan
// test), so the one-field prefix probe is exact.
assert_eq!(
make_record.p2, 1,
"composite prefix probe must be a single leading-term record, not a NULL-padded full key"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Null && op.p2 == make_record.p1 + 1),
"composite prefix probe must not NULL-pad the trailing indexed term"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Int64 && op.p4 == P4::Int64(i64::MIN)),
"single-key probes append a rowid floor; a composite prefix probe must not"
);
}
#[test]
fn test_codegen_ordered_scan_with_composite_equality_rechecks_prefix_boundary_each_next() {
let schema = test_schema_with_composite_prefix_index();
let table = schema
.iter()
.find(|table| table.name == "messages")
.expect("messages table");
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("idx"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("content"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(col_eq_param("conversation_id", 1)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("idx"), Span::ZERO),
direction: Some(fsqlite_ast::SortDirection::Asc),
nulls: None,
}],
limit: None,
};
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).expect("codegen ordered scan");
let prog = b.finish().expect("finish program");
let ops = prog.ops();
let idx_gt_addr = ops
.iter()
.position(|op| op.opcode == Opcode::IdxGT)
.expect("bounded ordered scan should emit IdxGT boundary");
let next = ops
.iter()
.find(|op| op.opcode == Opcode::Next)
.expect("ordered composite scan should iterate index entries");
assert_eq!(
next.p2, idx_gt_addr as i32,
"Next must jump back to the IdxGT boundary check so each scanned row stays within the equality-prefix duplicate run"
);
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "sqlite_autoindex_messages_1")
}),
"ordered scan should open the composite autoindex"
);
assert_eq!(
table.indexes[0].columns,
vec!["conversation_id".to_owned(), "idx".to_owned()],
"test assumes the composite ordered index is keyed by conversation_id then idx"
);
}
#[test]
fn test_codegen_select_with_index_wrong_qualifier_errors() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "b"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
})),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("mismatched qualifier should be a semantic error");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.b"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_select_with_index_range() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
col_cmp_param("b", AstBinaryOp::Gt, 1),
col_cmp_param("b", AstBinaryOp::Lt, 2),
)),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"indexed range should position with SeekGE on the index cursor"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Le),
"exclusive lower bound should skip entries equal to the low key"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Ge),
"exclusive upper bound should stop once the current key reaches the high key"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"range scan should open the matching index"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"index range should not fall back to rewinding the table cursor"
);
let next = ops
.iter()
.find(|op| op.opcode == Opcode::Next)
.expect("index range should advance through index entries");
assert_eq!(next.p1, 1, "Next should advance the index cursor");
}
#[test]
fn test_codegen_select_with_index_like_pure_prefix_uses_range_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("123%".to_owned()),
Span::ZERO,
)),
escape: None,
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
);
// TEXT+BINARY column: the case-stable prefix range is byte-exact here.
let schema = test_schema_with_text_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"pure trailing-percent LIKE prefixes should lower to indexed range seeks"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Ge),
"LIKE prefix range should stop once the current key reaches the derived upper bound"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"LIKE prefix range should open the matching index"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"LIKE prefix range should not fall back to a table rewind"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::LikeConstFast),
"pure prefix LIKE lowered as an index range should not keep the full-scan LIKE filter path"
);
}
#[test]
fn test_codegen_select_with_index_like_escaped_prefix_uses_range_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("123\\%%".to_owned()),
Span::ZERO,
)),
escape: Some(Box::new(Expr::Literal(
Literal::String("\\".to_owned()),
Span::ZERO,
))),
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
);
// TEXT+BINARY column: the case-stable escaped prefix range is byte-exact here.
let schema = test_schema_with_text_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"escaped pure-prefix LIKE patterns should lower to indexed range seeks"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Ge),
"escaped pure-prefix LIKE patterns should still stop at the derived upper bound"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"escaped pure-prefix LIKE range should open the matching index"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"escaped pure-prefix LIKE range should not fall back to a table rewind"
);
assert!(
!ops.iter().any(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "LIKE")
}),
"escaped pure-prefix LIKE range should not keep the generic LIKE runtime filter"
);
}
#[test]
fn test_codegen_select_with_index_like_escaped_underscore_prefix_uses_range_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("123\\_%".to_owned()),
Span::ZERO,
)),
escape: Some(Box::new(Expr::Literal(
Literal::String("\\".to_owned()),
Span::ZERO,
))),
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
);
// TEXT+BINARY column: the case-stable escaped prefix range is byte-exact here.
let schema = test_schema_with_text_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"escaped underscore-prefix LIKE patterns should lower to indexed range seeks"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Ge),
"escaped underscore-prefix LIKE patterns should still stop at the derived upper bound"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"escaped underscore-prefix LIKE range should open the matching index"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"escaped underscore-prefix LIKE range should not fall back to a table rewind"
);
assert!(
!ops.iter().any(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "LIKE")
}),
"escaped underscore-prefix LIKE range should not keep the generic LIKE runtime filter"
);
}
#[test]
fn test_codegen_select_with_index_like_embedded_wildcard_stays_on_filter_path() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("123%tail".to_owned()),
Span::ZERO,
)),
escape: None,
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"embedded-wildcard LIKE patterns should not be mis-lowered as exact range scans"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"unsupported LIKE shapes should stay on the ordinary scan path"
);
}
#[test]
fn test_codegen_select_upper_only_index_range_guards_null_keys() {
let stmt = simple_select(&["a"], "t", Some(col_cmp_param("b", AstBinaryOp::Lt, 1)));
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let key_read_idx = ops
.iter()
.position(|op| op.opcode == Opcode::Column && op.p1 == 1 && op.p2 == 0)
.expect("upper-only range should read the current index key");
let key_reg = ops[key_read_idx].p3;
let key_null_guard_idx = ops
.iter()
.position(|op| op.opcode == Opcode::IsNull && op.p1 == key_reg)
.expect("upper-only range should skip NULL index keys");
let upper_guard_idx = ops
.iter()
.position(|op| op.opcode == Opcode::Ge)
.expect("exclusive upper bound should emit Ge stop guard");
assert!(
key_read_idx < key_null_guard_idx,
"NULL guard should run after reading the current index key"
);
assert!(
key_null_guard_idx < upper_guard_idx,
"NULL guard should run before the upper-bound comparison"
);
}
#[test]
fn test_codegen_select_with_index_between_can_be_covering() {
let stmt = simple_select(
&["b"],
"t",
Some(Box::new(Expr::Between {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
low: Box::new(placeholder(1)),
high: Box::new(placeholder(2)),
not: false,
span: Span::ZERO,
})),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"BETWEEN should reuse the indexed range seek path"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Gt),
"inclusive upper bound should stop only after the current key exceeds the high key"
);
let table_open_count = ops
.iter()
.filter(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "t")
})
.count();
assert_eq!(
table_open_count, 0,
"covering index range should not open the table cursor"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"covering index range should not perform table rowid lookups"
);
}
#[test]
fn test_codegen_select_index_range_checks_offset_before_projection() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(and_expr(
col_cmp_param("b", AstBinaryOp::Ge, 1),
col_cmp_param("b", AstBinaryOp::Lt, 2),
)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(5), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(3), Span::ZERO)),
}),
};
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let ifpos_idx = ops
.iter()
.position(|op| op.opcode == Opcode::IfPos)
.expect("range scan with OFFSET should emit IfPos");
let seek_rowid_idx = ops
.iter()
.position(|op| op.opcode == Opcode::SeekRowid)
.expect("non-covering index range should seek into the table");
let projected_column_idx = ops
.iter()
.position(|op| op.opcode == Opcode::Column && op.p1 == 0 && op.p2 == 0)
.expect("non-covering index range should project table columns");
assert!(
seek_rowid_idx < ifpos_idx,
"OFFSET should only decrement after the row lookup succeeds"
);
assert!(
ifpos_idx < projected_column_idx,
"OFFSET skipping should happen before decoding projected columns"
);
}
#[test]
fn test_codegen_select_index_range_with_nocase_collation_falls_back() {
let stmt = simple_select(
&["name"],
"t",
Some(col_cmp_param("name", AstBinaryOp::Lt, 1)),
);
let schema = test_schema_with_nocase_text_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"NOCASE range should stay on the generic full-scan path"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_name")),
"unsafe collation semantics should not use the index-range fast path"
);
}
#[test]
fn test_codegen_select_index_range_with_numeric_affinity_uses_index() {
// bd-u6tbr enabled the placeholder-bound numeric-affinity range seek: the bound is coerced
// to the column's affinity ('D') so the seek positions identically to the WHERE comparison,
// making the index-range fast path byte-exact (differential oracle: index_range_seek_oracle_e2e).
// (Was previously asserted to "fall back" to a full scan; bd-u6tbr made that assertion stale.)
let stmt = simple_select(
&["n"],
"t",
Some(and_expr(
col_cmp_param("n", AstBinaryOp::Ge, 1),
col_cmp_param("n", AstBinaryOp::Lt, 2),
)),
);
let schema = test_schema_with_typed_numeric_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_n")),
"numeric-affinity placeholder range should use the index-range seek (bd-u6tbr)"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"the index-range seek must not fall back to a full table scan (Rewind on cursor 0)"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Affinity
&& matches!(&op.p4, P4::Affinity(a) if a == "D")),
"the placeholder bound should be coerced to the column's numeric affinity before the seek"
);
}
#[test]
fn test_codegen_select_index_range_with_anonymous_params_stays_on_full_scan() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Ge,
right: Box::new(anonymous_placeholder()),
span: Span::ZERO,
}),
Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Lt,
right: Box::new(anonymous_placeholder()),
span: Span::ZERO,
}),
)),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
ops.iter().any(|op| op.opcode == Opcode::Rewind),
"anonymous placeholder ranges must stay on the original full-scan path"
);
assert!(
!ops.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"fast-path index range seek must not reorder anonymous placeholders"
);
}
#[test]
fn test_codegen_select_index_range_with_wrong_qualifier_errors() {
let stmt = simple_select(
&["a"],
"t",
Some(and_expr(
qualified_col_cmp_param("u", "b", AstBinaryOp::Ge, 1),
qualified_col_cmp_param("u", "b", AstBinaryOp::Lt, 2),
)),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("mismatched qualifier should be a semantic error");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.b"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_select_where_in_subquery_supported_without_rewrite() {
let subquery = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(subquery)),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let open_reads = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::OpenRead)
.count();
assert_eq!(open_reads, 2, "outer + probe OpenRead expected");
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Eq | Opcode::Found)),
"expected IN membership probe"
);
}
#[test]
fn test_resolve_in_probe_source_subquery_supported() {
let subquery = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let set = InSet::Subquery(Box::new(subquery));
let schema = test_schema_with_subquery_source();
assert!(super::resolve_in_probe_source(&set, &schema).is_some());
}
#[test]
fn test_resolve_in_probe_source_table_supported() {
let set = InSet::Table(QualifiedName::bare("s"));
let schema = test_schema_with_subquery_source();
assert!(super::resolve_in_probe_source(&set, &schema).is_some());
}
#[test]
fn test_emit_in_probe_expr_fails_closed_when_probe_lowering_is_unsupported() {
let schema = test_schema_with_subquery_source();
let scan_ctx = ScanCtx {
cursor: 0,
table: &schema[0],
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let unsupported_subquery = select_sql("SELECT b, b FROM s");
let set = InSet::Subquery(Box::new(unsupported_subquery));
let operand = Expr::Column(ColumnRef::bare("a"), Span::ZERO);
let mut b = ProgramBuilder::new();
let result_reg = b.alloc_reg();
emit_in_probe_expr(&mut b, &operand, &set, false, result_reg, Some(&scan_ctx));
let program = b.finish().expect("error program should be valid VDBE");
assert_eq!(
program.ops(),
&[VdbeOp {
opcode: Opcode::Halt,
p1: ErrorCode::Internal as i32,
p2: 0,
p3: 0,
p4: P4::Str(
"IN probe codegen invariant failed: unsupported probe source".to_owned()
),
p5: 0,
}],
"an unsupported compiled IN probe must fail explicitly, never yield SQL NULL"
);
}
#[test]
fn row_value_in_list_executes_match_miss_unknown_not_and_empty_truth_tables() {
let stmt = select_sql(
"SELECT \
(1, 2) IN ((9, 9), (1, 2)), \
(1, 2) IN ((9, 9), (3, 4)), \
(1, 2) IN ((1, NULL), (3, 4)), \
(1, 2) IN ((NULL, 3)), \
(1, 2) NOT IN ((9, 9), (3, 4)), \
(1, 2) NOT IN ((1, NULL), (3, 4)), \
(NULL, 2) IN (), \
(NULL, 2) NOT IN ()",
);
let rows = execute_codegen_select_with_storage_cursor(&stmt, &[], MemDatabase::new());
assert_eq!(
rows,
vec![vec![
SqliteValue::Integer(1),
SqliteValue::Integer(0),
SqliteValue::Null,
SqliteValue::Integer(0),
SqliteValue::Integer(1),
SqliteValue::Null,
SqliteValue::Integer(0),
SqliteValue::Integer(1),
]],
"vector IN must preserve candidate-local NULL semantics and invert only definite results"
);
}
#[test]
fn row_value_in_list_uses_final_rhs_tuple_as_field_metadata_donor() {
let stmt = select_sql(
"SELECT \
(1, 2) IN ((CAST('1' AS TEXT), 2), (CAST('x' AS BLOB), 3)), \
(1, 2) IN ((CAST('x' AS BLOB), 3), (CAST('1' AS TEXT), 2)), \
('a', 1) IN (('A', 1), ('x' COLLATE NOCASE, 2)), \
('a', 1) IN (('x' COLLATE NOCASE, 2), ('A', 1))",
);
let rows = execute_codegen_select_with_storage_cursor(&stmt, &[], MemDatabase::new());
assert_eq!(
rows,
vec![vec![
SqliteValue::Integer(0),
SqliteValue::Integer(1),
SqliteValue::Integer(1),
SqliteValue::Integer(0),
]],
"reversing the final syntactic tuple must reverse affinity/collation donation"
);
}
#[test]
fn row_value_in_list_evaluates_each_volatile_lhs_field_once() {
use fsqlite_func::ScalarFunction;
use std::sync::atomic::{AtomicUsize, Ordering};
struct NextVectorValue(Arc<AtomicUsize>);
impl ScalarFunction for NextVectorValue {
fn invoke(&self, _args: &[SqliteValue]) -> fsqlite_error::Result<SqliteValue> {
let value = self.0.fetch_add(1, Ordering::Relaxed) + 1;
Ok(SqliteValue::Integer(
i64::try_from(value).expect("test counter should fit in i64"),
))
}
fn is_deterministic(&self) -> bool {
false
}
fn num_args(&self) -> i32 {
0
}
fn name(&self) -> &str {
"next_vector_value"
}
}
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = FunctionRegistry::new();
register_builtins(&mut registry);
registry.register_scalar(NextVectorValue(Arc::clone(&calls)));
let stmt = select_sql(
"SELECT (next_vector_value(), next_vector_value()) \
IN ((9, 9), (1, 2))",
);
let rows = execute_codegen_select_with_registry(&stmt, &[], MemDatabase::new(), registry);
assert_eq!(rows, vec![vec![SqliteValue::Integer(1)]]);
assert_eq!(
calls.load(Ordering::Relaxed),
2,
"each volatile LHS field must run once, independent of candidate count"
);
}
#[test]
fn row_value_in_list_fails_closed_on_arity_and_probe_shape_mismatches() {
let scalar = |value| Expr::Literal(Literal::Integer(value), Span::ZERO);
let lhs = Expr::RowValue(vec![scalar(1), scalar(2)], Span::ZERO);
let malformed_list = Expr::In {
expr: Box::new(lhs.clone()),
set: InSet::List(vec![Expr::RowValue(vec![scalar(1)], Span::ZERO)]),
not: false,
span: Span::ZERO,
};
let unmaterialized_probe = Expr::In {
expr: Box::new(lhs),
set: InSet::Subquery(Box::new(select_sql("SELECT 1, 2"))),
not: false,
span: Span::ZERO,
};
let runtime = RuntimeBuilder::current_thread()
.blocking_threads(1, 2)
.build()
.expect("build fail-closed row-value test runtime");
for (expr, expected_reason) in [
(
malformed_list,
"row-value IN arity mismatch: expected 2, found 1",
),
(
unmaterialized_probe,
"multi-column subquery/table probes require prior list materialization",
),
] {
let mut builder = ProgramBuilder::new();
let result = builder.alloc_reg();
emit_expr(&mut builder, &expr, result, None);
let program = builder
.finish()
.expect("fail-closed row-value program should finish");
assert_eq!(
program.ops(),
&[VdbeOp {
opcode: Opcode::Halt,
p1: ErrorCode::Internal as i32,
p2: 0,
p3: 0,
p4: P4::Str(format!(
"IN probe codegen invariant failed: {expected_reason}"
)),
p5: 0,
}],
"unsupported vector probe shape must never degrade to a scalar NULL result"
);
let mut engine = VdbeEngine::new(program.register_count());
let outcome = runtime
.block_on(async { engine.execute(&program).await })
.expect("fail-closed program execution should return a VDBE outcome");
assert_eq!(
outcome,
ExecOutcome::Error {
code: ErrorCode::Internal as i32,
message: format!("IN probe codegen invariant failed: {expected_reason}"),
},
"unsupported vector probe shape must surface the codegen invariant at runtime"
);
}
}
fn in_subquery_program_ops(sql: &str, schema: &[TableSchema]) -> Vec<VdbeOp> {
let stmt = select_sql(sql);
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, schema, &CodegenContext::default())
.expect("IN-subquery fixture should compile");
b.finish()
.expect("IN-subquery program should finish")
.ops()
.to_vec()
}
#[test]
fn complex_in_ordered_top_n_emits_independent_key_before_projection() {
let schema = test_schema_with_subquery_source();
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT out() FROM s ORDER BY key() LIMIT 1) FROM t",
&schema,
);
let function_index = |name: &str| {
ops.iter()
.position(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(actual) if actual == name)
})
.unwrap_or_else(|| {
assert!(test_failure(), "expected {name} function opcode");
usize::MAX
})
};
assert!(
function_index("KEY") < function_index("OUT"),
"a non-DISTINCT bounded sorter must compute an independent ORDER key \
before the projected RHS value"
);
let preflight_index = ops
.iter()
.position(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
})
.expect("bounded complex-IN sorter should preflight each candidate key");
assert!(
function_index("KEY") < preflight_index && preflight_index < function_index("OUT"),
"a rejected candidate must branch around its projected RHS value"
);
let sorter_open = ops
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered RHS should open a sorter");
assert_eq!(
sorter_open.p5, SORTER_OPEN_TOP_N_REGISTER,
"complex-IN sorter should read its top-N bound from the LIMIT register"
);
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::Integer && op.p1 == 1 && op.p2 == sorter_open.p3
}),
"constant LIMIT 1 should populate the complex-IN top-N bound register"
);
}
#[test]
fn complex_in_exact_output_order_reference_reuses_volatile_projection() {
let schema = test_schema_with_subquery_source();
for sql in [
"SELECT a IN (SELECT out() FROM s ORDER BY out() LIMIT 1) FROM t",
"SELECT a IN (SELECT out() AS x FROM s ORDER BY x COLLATE BINARY LIMIT 1) FROM t",
"SELECT a IN (SELECT out() FROM s ORDER BY +1 LIMIT 1) FROM t",
"SELECT a IN (SELECT out(b) FROM s ORDER BY out(s.b) LIMIT 1) FROM t",
"SELECT a IN (SELECT out(b) FROM s ORDER BY out(B) LIMIT 1) FROM t",
"SELECT a IN (SELECT out(b) FROM s AS x ORDER BY out(x.b) LIMIT 1) FROM t",
"SELECT a IN (SELECT out(rowid) FROM s ORDER BY out(_rowid_) LIMIT 1) FROM t",
"SELECT a IN (SELECT out(rowid) FROM s ORDER BY out(oid) LIMIT 1) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
let output_calls = ops
.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count();
assert_eq!(
output_calls, 1,
"exact output alias/ordinal/expression should share one emitted value: `{sql}`"
);
}
let collated_expr_ops = in_subquery_program_ops(
"SELECT a IN \
(SELECT out() FROM s ORDER BY out() COLLATE BINARY LIMIT 1) FROM t",
&schema,
);
let output_calls = collated_expr_ops
.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count();
assert_eq!(
output_calls, 2,
"a separately written COLLATE-wrapped output expression is not an alias reference"
);
}
#[test]
fn complex_in_exact_output_reuses_integer_primary_key_for_rowid_order_reference() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns = vec![ColumnInfo::basic("id", 'D', true)];
let sql = "SELECT a IN (SELECT out(id) FROM s ORDER BY out(rowid) LIMIT 1) FROM t";
let ops = in_subquery_program_ops(sql, &schema);
let output_calls = ops
.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count();
assert_eq!(
output_calls, 1,
"an INTEGER PRIMARY KEY alias and rowid name the same projected value"
);
}
#[test]
fn scalar_subquery_affinity_reaches_comparison_codegen_in_both_operand_orders() {
let schema = scalar_affinity_test_schema();
let compile = |sql: &str| {
let stmt = select_sql(sql);
let mut builder = ProgramBuilder::new();
codegen_select(&mut builder, &stmt, &schema, &CodegenContext::default())
.expect("scalar-subquery comparison should compile");
builder
.finish()
.expect("scalar-subquery comparison program should finish")
.ops()
.to_vec()
};
for (scalar, other, expected_p5) in [
("(SELECT n FROM s)", "'5'", u16::from(b'C')),
("(SELECT txt FROM s)", "5", u16::from(b'B')),
("(SELECT raw FROM s)", "5", 0),
] {
for sql in [
format!("SELECT {scalar} = {other} FROM t"),
format!("SELECT {other} = {scalar} FROM t"),
] {
let ops = compile(&sql);
let comparisons = ops
.iter()
.filter(|op| op.opcode == Opcode::Eq)
.collect::<Vec<_>>();
assert_eq!(
comparisons.len(),
1,
"fixture must emit one equality comparison: `{sql}`",
);
assert_eq!(comparisons[0].p5, expected_p5, "wrong P5 for `{sql}`");
assert_eq!(
comparisons[0].p4,
P4::None,
"a scalar result's inner declared collation must not cross the subquery boundary: `{sql}`",
);
}
}
let case_ops = compile("SELECT CASE (SELECT n FROM s) WHEN '5' THEN 1 ELSE 0 END FROM t");
let case_comparison = case_ops
.iter()
.find(|op| op.opcode == Opcode::Ne)
.expect("simple CASE should emit a comparison");
assert_eq!(case_comparison.p5, u16::from(b'C'));
let between_ops = compile("SELECT (SELECT txt FROM s) BETWEEN 1 AND 2 FROM t");
let between_comparisons = between_ops
.iter()
.filter(|op| matches!(op.opcode, Opcode::Lt | Opcode::Gt))
.collect::<Vec<_>>();
assert_eq!(between_comparisons.len(), 2);
assert!(
between_comparisons
.iter()
.all(|op| { op.p5 == u16::from(b'B') && matches!(op.p4, P4::None) })
);
}
#[test]
fn fallback_and_join_scalar_subquery_affinity_use_the_local_result_scope() {
let schema = scalar_affinity_test_schema();
let inner = ScanCtx {
cursor: 3,
table: &schema[0],
table_alias: Some("t"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let join_tables = [(&schema[0], Some("t")), (&schema[1], Some("outer_s"))];
for (scalar, other, expected_p5) in [
("(SELECT n FROM s)", "'5'", u16::from(b'C')),
("(SELECT txt FROM s)", "5", u16::from(b'B')),
("(SELECT raw FROM s)", "5", 0),
] {
for sql in [format!("{scalar} = {other}"), format!("{other} = {scalar}")] {
let expr = expr_sql(&sql);
let Expr::BinaryOp { left, right, .. } = &expr else {
unreachable!("expected comparison expression");
};
let mut builder = ProgramBuilder::new();
let result = builder.alloc_reg();
emit_expr_with_fallback(&mut builder, &expr, result, &inner, None);
builder.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
let program = builder
.finish()
.expect("fallback scalar-subquery program should finish");
let comparison = program
.ops()
.iter()
.find(|op| op.opcode == Opcode::Eq)
.expect("fallback expression should emit equality");
assert_eq!(comparison.p5, expected_p5, "wrong fallback P5 for `{sql}`");
assert_eq!(
join_comparison_affinity_p5(left, right, &join_tables),
expected_p5,
"wrong join-codegen P5 for `{sql}`",
);
}
}
}
#[test]
fn comparison_metadata_uses_column_info_without_declared_type_text() {
let mut column = ColumnInfo::basic("value", 'B', false);
column.collation = Some("NOCASE".to_owned());
let schema = vec![TableSchema {
name: "materialized_view".to_owned(),
root_page: 2,
columns: vec![column],
indexes: Vec::new(),
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let scan = ScanCtx {
cursor: 3,
table: &schema[0],
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let literal = expr_sql("1");
let column = expr_sql("value");
let resolved = resolve_column_ref(&column, scan.table, scan.table_alias);
assert_eq!(expr_affinity(&column, Some(&scan)), b'B');
assert_eq!(
resolved_expr_affinity(&column, resolved.as_ref(), &scan),
b'B'
);
assert_eq!(join_expr_affinity(&column, &[(&schema[0], None)]), b'B');
let comparison = ResolvedComparisonInfo::new(&literal, &column, &scan);
assert_eq!(comparison.cmp_p5, 0x80 | u16::from(b'B'));
assert!(matches!(
comparison.collation_p4,
P4::Collation(ref name) if name == "NOCASE"
));
}
#[test]
fn scalar_subquery_affinity_uses_outer_and_parser_root_metadata() {
let schema = scalar_affinity_test_schema();
let outer = ScanCtx {
cursor: 3,
table: &schema[0],
table_alias: Some("t"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let inner = ScanCtx {
cursor: 4,
table: &schema[1],
table_alias: Some("s"),
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let join_tables = [(&schema[0], Some("t")), (&schema[1], Some("s"))];
let compact_schema = test_schema_with_subquery_source();
let compact_outer = ScanCtx {
cursor: 5,
table: &compact_schema[0],
table_alias: Some("t"),
schema: Some(&compact_schema),
register_base: None,
secondaries: &[],
};
assert_eq!(
expr_affinity(&expr_sql("(SELECT b FROM s)"), Some(&compact_outer)),
b'D',
"scalar result affinity must use ColumnInfo.affinity when declared type text is absent",
);
let correlated_outer = expr_sql("(SELECT t.outer_n)");
assert_eq!(expr_affinity(&correlated_outer, Some(&outer)), b'C');
assert_eq!(
fallback_expr_affinity(&correlated_outer, &inner, Some(&outer)),
b'C',
);
assert_eq!(join_expr_affinity(&correlated_outer, &join_tables), b'C');
let mut bound_select = select_sql("SELECT 0");
let SelectCore::Select { columns, .. } = &mut bound_select.body.select else {
unreachable!("expected SELECT core");
};
columns[0] = ResultColumn::Expr {
expr: Expr::BoundOuterValue {
value: SqliteValue::Text("5".into()),
collation: BoundCollation::Named("NOCASE".to_owned()),
affinity: Some(TypeAffinity::Numeric),
span: Span::ZERO,
},
alias: None,
};
let bound_scalar = Expr::Subquery(Box::new(bound_select), Span::ZERO);
assert_eq!(expr_affinity(&bound_scalar, None), b'C');
assert_eq!(
fallback_expr_affinity(&bound_scalar, &inner, Some(&outer)),
b'C'
);
assert_eq!(join_expr_affinity(&bound_scalar, &join_tables), b'C');
let deferred_values = expr_sql("(VALUES (CAST(1 AS INTEGER)), (CAST('2' AS TEXT)))");
assert_eq!(
expr_affinity(&deferred_values, None),
b'A',
"an unresolved VALUES representation must not guess a donor affinity",
);
assert_eq!(
fallback_expr_affinity(&deferred_values, &inner, Some(&outer)),
b'A'
);
assert_eq!(join_expr_affinity(&deferred_values, &join_tables), b'A');
let values_agree = scalar_values_with_frozen_donor(
"(VALUES (CAST(1 AS INTEGER)), (CAST(2 AS INTEGER)))",
0,
);
let values_integer_then_text = scalar_values_with_frozen_donor(
"(VALUES (CAST(1 AS INTEGER)), (CAST('2' AS TEXT)))",
0,
);
let values_text_then_integer = scalar_values_with_frozen_donor(
"(VALUES (CAST('2' AS TEXT)), (CAST(1 AS INTEGER)))",
0,
);
assert_eq!(expr_affinity(&values_agree, None), b'D');
assert_eq!(expr_affinity(&values_integer_then_text, None), b'D');
assert_eq!(expr_affinity(&values_text_then_integer, None), b'B');
assert_eq!(
fallback_expr_affinity(&values_integer_then_text, &inner, Some(&outer)),
b'D'
);
assert_eq!(
join_expr_affinity(&values_integer_then_text, &join_tables),
b'D'
);
let values_integer_then_text_second_donor = scalar_values_with_frozen_donor(
"(VALUES (CAST(1 AS INTEGER)), (CAST('2' AS TEXT)))",
1,
);
let values_text_then_integer_second_donor = scalar_values_with_frozen_donor(
"(VALUES (CAST('2' AS TEXT)), (CAST(1 AS INTEGER)))",
1,
);
assert_eq!(
expr_affinity(&values_integer_then_text_second_donor, None),
b'B'
);
assert_eq!(
expr_affinity(&values_text_then_integer_second_donor, None),
b'D'
);
let compound_agrees =
expr_sql("(SELECT CAST(1 AS INTEGER) UNION ALL SELECT CAST(2 AS INTEGER))");
let compound_integer_then_text =
expr_sql("(SELECT CAST(1 AS INTEGER) UNION ALL SELECT CAST('2' AS TEXT))");
let compound_text_then_integer =
expr_sql("(SELECT CAST('2' AS TEXT) UNION ALL SELECT CAST(1 AS INTEGER))");
assert_eq!(expr_affinity(&compound_agrees, None), b'D');
assert_eq!(expr_affinity(&compound_integer_then_text, None), b'B');
assert_eq!(expr_affinity(&compound_text_then_integer, None), b'D');
let with_base_table = expr_sql("(WITH c(x) AS (VALUES (0)) SELECT n FROM s)");
assert_eq!(expr_affinity(&with_base_table, Some(&outer)), b'C');
let with_integer_then_text = expr_sql(
"(WITH c(x) AS (VALUES (0)) \
SELECT CAST(1 AS INTEGER) UNION ALL SELECT CAST('2' AS TEXT))",
);
let with_text_then_integer = expr_sql(
"(WITH c(x) AS (VALUES (0)) \
SELECT CAST('2' AS TEXT) UNION ALL SELECT CAST(1 AS INTEGER))",
);
assert_eq!(expr_affinity(&with_integer_then_text, None), b'B');
assert_eq!(expr_affinity(&with_text_then_integer, None), b'D');
}
#[test]
fn complex_in_uncorrelated_scalar_lhs_preserves_affinity_and_rhs_first_order() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns[0].affinity = 'B';
schema[1].columns[0].type_name = Some("TEXT".to_owned());
let mut integer_column = ColumnInfo::basic("x", 'D', false);
integer_column.type_name = Some("INTEGER".to_owned());
schema.push(TableSchema {
name: "scalar_lhs".to_owned(),
root_page: 4,
columns: vec![integer_column],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
});
for sql in [
"SELECT (SELECT x FROM scalar_lhs) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
"SELECT (SELECT CAST(1 AS INTEGER)) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
let rhs_open = ops
.iter()
.position(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Table(name) if name == "s")
})
.expect("complex RHS should open s");
let scalar_open = ops
.iter()
.position(|op| op.opcode == Opcode::OpenRead && op.p2 == schema[2].root_page);
if sql.contains("FROM scalar_lhs") {
assert!(
scalar_open.is_some_and(|index| rhs_open < index),
"the RHS must be materialized before opening the scalar-LHS source"
);
} else {
assert!(
scalar_open.is_none(),
"a FROM-less scalar LHS should not open a source cursor"
);
}
let opened_cursors = ops
.iter()
.filter(|op| {
matches!(
op.opcode,
Opcode::OpenRead | Opcode::OpenAutoindex | Opcode::SorterOpen
)
})
.map(|op| op.p1)
.collect::<Vec<_>>();
assert_eq!(
opened_cursors
.iter()
.copied()
.collect::<std::collections::HashSet<_>>()
.len(),
opened_cursors.len(),
"nested scalar and complex-IN sources must use disjoint cursor identifiers"
);
assert!(
ops.iter()
.filter(|op| {
op.opcode == Opcode::Affinity
&& matches!(&op.p4, P4::Affinity(affinity) if affinity == "C")
})
.count()
>= 2,
"INTEGER scalar-result affinity must numerically coerce the TEXT RHS and probe"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::Halt && op.p1 != 0),
"eligible uncorrelated scalar LHS must remain on native complex-IN codegen"
);
}
}
#[test]
fn complex_in_uncorrelated_scalar_lhs_applies_affinity_at_runtime() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns[0].affinity = 'B';
schema[1].columns[0].type_name = Some("TEXT".to_owned());
let mut integer_column = ColumnInfo::basic("x", 'D', false);
integer_column.type_name = Some("INTEGER".to_owned());
schema.push(TableSchema {
name: "scalar_lhs".to_owned(),
root_page: 4,
columns: vec![integer_column],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
});
let mut db = MemDatabase::new();
db.create_table_at(2, 2);
db.get_table_mut(2)
.expect("outer table should exist")
.insert_row(1, vec![SqliteValue::Integer(0), SqliteValue::Integer(0)]);
db.create_table_at(3, 1);
db.get_table_mut(3)
.expect("RHS table should exist")
.insert_row(1, vec![SqliteValue::Text("01".into())]);
db.create_table_at(4, 1);
db.get_table_mut(4)
.expect("scalar table should exist")
.insert_row(1, vec![SqliteValue::Integer(1)]);
let stmt = select_sql(
"SELECT (SELECT x FROM scalar_lhs) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
);
let rows = execute_codegen_select_with_storage_cursor(&stmt, &schema, db);
assert_eq!(
rows,
vec![vec![SqliteValue::Integer(1)]],
"INTEGER scalar-result affinity must make text '01' match integer 1"
);
}
#[test]
fn complex_in_scalar_lhs_does_not_inherit_inner_result_collation() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns[0].type_name = Some("TEXT".to_owned());
let mut nocase_column = ColumnInfo::basic("x", 'B', false);
nocase_column.type_name = Some("TEXT".to_owned());
nocase_column.collation = Some("NOCASE".to_owned());
schema.push(TableSchema {
name: "scalar_lhs".to_owned(),
root_page: 4,
columns: vec![nocase_column],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
});
for sql in [
"SELECT (SELECT x FROM scalar_lhs) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
"SELECT (SELECT x COLLATE NOCASE FROM scalar_lhs) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
let membership = ops
.iter()
.find(|op| op.opcode == Opcode::OpenAutoindex)
.expect("complex IN should open its membership index");
assert_eq!(
membership.p4,
P4::None,
"a scalar subquery's inner result collation does not cross its SQL boundary: `{sql}`"
);
}
}
#[test]
fn complex_in_rejects_correlated_or_function_bearing_scalar_lhs() {
let mut schema = test_schema_with_subquery_source();
schema.push(TableSchema {
name: "scalar_lhs".to_owned(),
root_page: 4,
columns: vec![ColumnInfo::basic("x", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
});
for sql in [
"SELECT (SELECT a) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
"SELECT (SELECT t.a FROM scalar_lhs) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
"SELECT (SELECT out()) IN \
(SELECT b FROM s ORDER BY rowid LIMIT 1) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SorterOpen),
"a scalar LHS with outer scope or function semantics must not enter native complex-IN lowering: `{sql}`"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Halt && op.p1 == ErrorCode::Internal as i32),
"direct codegen must fail closed for an ineligible scalar LHS: `{sql}`"
);
}
}
#[test]
fn complex_in_distinct_function_output_order_reference_is_stored() {
let schema = test_schema_with_subquery_source();
for sql in [
"SELECT a IN \
(SELECT DISTINCT out(b) AS x FROM s ORDER BY x LIMIT 1) FROM t",
"SELECT a IN \
(SELECT DISTINCT out(b) FROM s ORDER BY 1 LIMIT 1) FROM t",
"SELECT a IN \
(SELECT DISTINCT out(b) FROM s ORDER BY out(s.b) LIMIT 1) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
let output_calls = ops
.iter()
.enumerate()
.filter_map(|(index, op)| {
(op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT"))
.then_some(index)
})
.collect::<Vec<_>>();
assert_eq!(
output_calls.len(),
1,
"exact alias/ordinal/qualified-structural ordered DISTINCT must emit \
exactly one projected function evaluation: `{sql}`"
);
let distinct_probe = ops
.iter()
.position(|op| op.opcode == Opcode::Found)
.expect("ordered DISTINCT should probe pass-1 membership");
let source_cursor = ops
.iter()
.find(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Table(name) if name == "s")
})
.map(|op| op.p1)
.expect("complex-IN should open source table s");
let source_close = ops
.iter()
.position(|op| op.opcode == Opcode::Close && op.p1 == source_cursor)
.expect("complex-IN should close source table s");
assert!(
output_calls[0] < distinct_probe && distinct_probe < source_close,
"pass 1 must preserve output-first membership and retain that value \
before closing its source cursor: `{sql}`"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"function-bearing ordered DISTINCT must not re-evaluate a representative: `{sql}`"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SorterOpen)
&& !ops
.iter()
.any(|op| op.opcode == Opcode::Halt && op.p1 == ErrorCode::Internal as i32),
"supported ordered-DISTINCT projection must stay in native complex-IN lowering: `{sql}`"
);
}
}
#[test]
fn table_local_expression_matching_rejects_foreign_qualifiers() {
let schema = test_schema_with_subquery_source();
let stored = expr_sql("out(b)");
let foreign = expr_sql("out(other.b)");
assert!(
!expressions_match_table_locally(&foreign, &stored, &schema[1], None),
"a foreign qualifier must never be stripped into a local expression match"
);
}
#[test]
fn complex_in_no_order_offset_skips_before_projection() {
let schema = test_schema_with_subquery_source();
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT tick() FROM s LIMIT 1 OFFSET 1) FROM t",
&schema,
);
let offset_skip = ops
.iter()
.position(|op| op.opcode == Opcode::IfPos)
.expect("OFFSET should emit IfPos");
let projection = ops
.iter()
.position(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "TICK")
})
.expect("projection should emit tick()");
assert!(
offset_skip < projection,
"non-DISTINCT OFFSET rows must be skipped before evaluating the RHS projection"
);
}
#[test]
fn complex_in_validates_limit_before_opening_source_table() {
let schema = test_schema_with_subquery_source();
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT b FROM s ORDER BY b LIMIT ?1) FROM t",
&schema,
);
let limit_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 1)
.expect("dynamic LIMIT should emit Variable ?1");
let limit_coercion = ops
.iter()
.position(|op| op.opcode == Opcode::MustBeInt)
.expect("dynamic LIMIT should emit MustBeInt");
let source_open = ops
.iter()
.position(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "s")
})
.expect("complex RHS should open source table s");
assert!(
limit_variable < limit_coercion && limit_coercion < source_open,
"LIMIT evaluation/coercion must precede RHS table I/O"
);
let sorter_open = ops
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered complex-IN RHS should open a sorter");
assert_eq!(
sorter_open.p5, SORTER_OPEN_TOP_N_REGISTER,
"dynamic complex-IN LIMIT should supply a runtime top-N bound"
);
assert_eq!(
sorter_open.p3, ops[limit_variable].p2,
"without OFFSET, SorterOpen should read the coerced LIMIT register directly"
);
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
}),
"dynamic complex-IN LIMIT should preflight keys before projection"
);
}
#[test]
fn complex_in_raw_non_numbered_operand_and_limit_fail_closed() {
let schema = test_schema_with_subquery_source();
for sql in [
"SELECT ? IN (SELECT b FROM s ORDER BY b LIMIT ?) FROM t",
"SELECT :lhs IN (SELECT b FROM s ORDER BY b LIMIT :lim) FROM t",
] {
let ops = in_subquery_program_ops(sql, &schema);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SorterOpen),
"raw non-numbered parameters must not enter emission-reordered complex IN: `{sql}`"
);
assert!(
ops.iter()
.any(|op| { op.opcode == Opcode::Halt && op.p1 == ErrorCode::Internal as i32 }),
"direct raw codegen must fail closed until slots are canonicalized: `{sql}`"
);
}
}
#[test]
fn complex_in_rejects_negative_order_ordinal() {
let schema = test_schema_with_subquery_source();
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT b FROM s ORDER BY -1 LIMIT 1) FROM t",
&schema,
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SorterOpen),
"negative ORDER ordinal must not degrade into a constant sort key"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Halt && op.p1 == ErrorCode::Internal as i32),
"connection routing should intercept the declined out-of-range ordinal"
);
}
#[test]
fn complex_in_single_star_reads_integer_primary_key_as_rowid() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns = vec![ColumnInfo::basic("id", 'D', true)];
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT * FROM s ORDER BY +1 LIMIT 1) FROM t",
&schema,
);
let source_cursor = ops
.iter()
.find_map(|op| {
(op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "s"))
.then_some(op.p1)
})
.expect("complex RHS should open s");
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rowid && op.p1 == source_cursor),
"one-column star over an INTEGER PRIMARY KEY must read the rowid value"
);
let probe_source = InProbeSource {
table: &schema[1],
table_alias: None,
where_clause: None,
value: InProbeValue::FirstColumn,
};
let probe_scan = ScanCtx {
cursor: source_cursor,
table: &schema[1],
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
assert_eq!(
in_probe_value_affinity(&probe_source, &probe_scan),
b'D',
"INTEGER PRIMARY KEY star output must retain INTEGER affinity"
);
}
#[test]
fn complex_in_exact_alias_collision_uses_output_collation() {
let mut schema = test_schema_with_subquery_source();
schema[1].columns = vec![
ColumnInfo {
name: "k".to_owned(),
affinity: 'B',
is_ipk: false,
type_name: Some("TEXT".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: Some("BINARY".to_owned()),
conflict_action: None,
},
ColumnInfo {
name: "v".to_owned(),
affinity: 'B',
is_ipk: false,
type_name: Some("TEXT".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: Some("NOCASE".to_owned()),
conflict_action: None,
},
];
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT v AS k FROM s ORDER BY k LIMIT 1) FROM t",
&schema,
);
let sorter_open = ops
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered RHS should open sorter");
let P4::Str(sorter_p4) = &sorter_open.p4 else {
assert!(
test_failure(),
"unexpected sorter metadata: {:?}",
sorter_open.p4
);
return;
};
assert_eq!(
sorter_p4, "+|NOCASE",
"bare ORDER alias wins a same-named BINARY source column and inherits output NOCASE"
);
let membership_open = ops
.iter()
.find(|op| op.opcode == Opcode::OpenAutoindex)
.expect("complex IN should materialize its selected membership set");
assert_eq!(
membership_open.p4,
P4::Collation("NOCASE".to_owned()),
"the RHS output collation must govern the final membership probe"
);
}
#[test]
fn complex_in_rewrites_aliases_nested_in_order_expressions() {
let ops = in_subquery_program_ops(
"SELECT a IN (SELECT b AS k FROM s ORDER BY k + 0 LIMIT 1) FROM t",
&test_schema_with_subquery_source(),
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::SorterOpen),
"nested ORDER alias should stay in native complex-IN lowering"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Add),
"nested ORDER alias should emit its rewritten arithmetic expression"
);
let source_cursor = ops
.iter()
.find_map(|op| {
(op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "s"))
.then_some(op.p1)
})
.expect("complex RHS should open s");
let add_index = ops
.iter()
.position(|op| op.opcode == Opcode::Add)
.expect("rewritten ORDER expression should add");
assert!(
ops[..add_index]
.iter()
.any(|op| op.opcode == Opcode::Column && op.p1 == source_cursor && op.p2 == 0),
"rewritten ORDER expression should read source column b"
);
assert!(
!ops.iter()
.any(|op| op.opcode == Opcode::Halt && op.p1 == ErrorCode::Internal as i32),
"validation must apply the same nested alias rewrite as code generation"
);
}
#[test]
fn nested_explicit_and_preserved_declared_collations_are_discovered() {
let nested = expr_sql("('a' COLLATE NOCASE) || ''");
assert_eq!(extract_collation(&nested), Some("NOCASE"));
let schema = test_schema_with_nocase_text_column();
let scan = ScanCtx {
cursor: 0,
table: &schema[0],
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
for sql in ["+name", "CAST(name AS TEXT)"] {
let expr = expr_sql(sql);
assert_eq!(
effective_collation_ctx(&expr, Some(&scan)),
Some("NOCASE"),
"declared collation should survive `{sql}`"
);
}
}
#[test]
fn bound_outer_text_value_emits_losslessly_and_retains_only_declared_metadata() {
let expr = Expr::BoundOuterValue {
value: SqliteValue::Text("outer value".into()),
collation: BoundCollation::Named("NOCASE".to_owned()),
affinity: Some(TypeAffinity::Text),
span: Span::ZERO,
};
assert_eq!(extract_collation(&expr), None);
assert_eq!(declared_collation_ctx(&expr, None), Some("NOCASE"));
assert_eq!(expr_affinity(&expr, None), b'B');
assert!(!expr_contains_non_numbered_placeholder(&expr));
assert!(!expr_contains_function_call(&expr));
assert!(!expr_has_window(&expr));
assert!(!is_aggregate_expr(&expr));
assert!(validate_explicit_index_expr_shape(&expr, "index key").is_err());
let binary_no_affinity = Expr::BoundOuterValue {
value: SqliteValue::Null,
collation: BoundCollation::Binary,
affinity: None,
span: Span::ZERO,
};
assert_eq!(
declared_collation_ctx(&binary_no_affinity, None),
Some("BINARY"),
);
assert_eq!(expr_affinity(&binary_no_affinity, None), b'A');
let unspecified = Expr::BoundOuterValue {
value: SqliteValue::Text("outer value".into()),
collation: BoundCollation::Unspecified,
affinity: None,
span: Span::ZERO,
};
assert_eq!(declared_collation_ctx(&unspecified, None), None);
assert_eq!(bound_outer_declared_collation(&unspecified), None);
assert_eq!(
comparison_collation_ctx(&unspecified, &expr, None),
Some("NOCASE".to_owned()),
"metadata-neutral bound values must not prevent a later operand from donating collation",
);
assert_eq!(
comparison_collation_ctx(&binary_no_affinity, &expr, None),
Some("BINARY".to_owned()),
"known BINARY bound columns must stop declared-collation precedence",
);
let mut builder = ProgramBuilder::new();
let output = builder.alloc_reg();
emit_expr(&mut builder, &expr, output, None);
let op = builder
.op_at(0)
.expect("bound value emission should produce one opcode");
assert_eq!(op.opcode, Opcode::String8);
assert_eq!(op.p2, output);
assert_eq!(op.p4, P4::Str("outer value".to_owned()));
}
#[test]
fn bound_outer_values_are_not_admitted_as_raw_physical_seek_keys() {
let bound = Expr::BoundOuterValue {
value: SqliteValue::Text("5".into()),
collation: BoundCollation::Named("NOCASE".to_owned()),
affinity: Some(TypeAffinity::Text),
span: Span::ZERO,
};
assert!(!is_simple_constant(&bound));
assert!(!is_rowid_range_constant(&bound));
assert!(!is_index_range_constant(&bound));
assert!(is_simple_constant(&placeholder(1)));
assert!(is_rowid_range_constant(&placeholder(1)));
assert!(is_index_range_constant(&placeholder(1)));
assert!(is_simple_constant(&Expr::Literal(
Literal::Integer(5),
Span::ZERO,
)));
let schema = test_schema_with_index();
let table = &schema[0];
let indexed_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(extract_column_eq_target(Some(&indexed_eq), table, None).is_none());
assert!(
extract_index_equality_prefix_exprs(&table.indexes[0], table, None, Some(&indexed_eq))
.is_empty(),
);
let indexed_range = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Gt,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(extract_column_range_target(Some(&indexed_range), table, None).is_none());
let rowid_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(extract_rowid_target_expr(Some(&rowid_eq), Some(table), None).is_none());
let rowid_range = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Gt,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(extract_rowid_range_target(Some(&rowid_range), Some(table), None).is_none());
let expression_schema = test_schema_with_expression_index();
let expression_table = &expression_schema[0];
let expression_eq = Expr::BinaryOp {
left: Box::new(expr_sql("lower(name)")),
op: AstBinaryOp::Eq,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(
extract_expression_index_equality_expr(
Some(&expression_eq),
&expression_table.indexes[0],
expression_table,
None,
)
.is_none(),
);
let expression_range = Expr::BinaryOp {
left: Box::new(expr_sql("lower(name)")),
op: AstBinaryOp::Lt,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(
extract_expression_index_range_target(
Some(&expression_range),
&expression_table.indexes[0],
expression_table,
None,
)
.is_none(),
);
let subquery_schema = test_schema_with_subquery_source();
let inner_table = &subquery_schema[1];
let correlated_rowid_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("s", "rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(bound.clone()),
span: Span::ZERO,
};
assert!(extract_exists_rowid_probe(&correlated_rowid_eq, inner_table, Some("s")).is_none());
let nested_bound_rowid_eq = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("s", "rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::UnaryOp {
op: fsqlite_ast::UnaryOp::Plus,
expr: Box::new(bound),
span: Span::ZERO,
}),
span: Span::ZERO,
};
assert!(
extract_exists_rowid_probe(&nested_bound_rowid_eq, inner_table, Some("s")).is_none(),
);
}
#[test]
fn indexed_count_exists_rowid_probe_requires_numeric_outer_storage() {
let stmt = agg_count_star_exists_rowid_probe();
let mut schema = test_schema_with_index_and_subquery_source();
let SelectCore::Select {
where_clause: Some(where_clause),
..
} = &stmt.body.select
else {
unreachable!("fixture must have a WHERE clause");
};
assert!(
extract_count_indexed_exists_target(Some(where_clause), &schema[0], None, &schema)
.is_some(),
"numeric outer index remains on the rowid-probe fast path",
);
schema[0].columns[1].affinity = 'B';
schema[0].columns[1].collation = Some("NOCASE".to_owned());
assert!(
extract_count_indexed_exists_target(Some(where_clause), &schema[0], None, &schema)
.is_none(),
"TEXT/NOCASE outer storage must not be probed with raw rowid keys",
);
}
#[test]
fn resolve_in_probe_source_rejects_schema_qualified_table_shorthand() {
let set = InSet::Table(QualifiedName::qualified("main", "s"));
let schema = test_schema_with_subquery_source();
assert!(resolve_in_probe_source(&set, &schema).is_none());
}
#[test]
fn test_codegen_select_where_in_table_supported_without_rewrite() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Table(QualifiedName::bare("s")),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let open_reads = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::OpenRead)
.count();
assert_eq!(open_reads, 2, "outer + probe OpenRead expected");
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Eq | Opcode::Found)),
"expected IN membership probe"
);
}
// === Test 10: INSERT RETURNING ===
#[test]
fn test_codegen_insert_returning() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("rowid"), Span::ZERO),
alias: None,
}],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// With RETURNING, there should be a ResultRow after Insert.
assert!(has_opcodes(
&prog,
&[Opcode::Insert, Opcode::ResultRow, Opcode::Close,]
));
}
#[test]
fn test_codegen_insert_returning_expression_wrong_qualifier_errors() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![ResultColumn::Expr {
expr: Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
},
alias: None,
}],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx)
.expect_err("RETURNING expression should reject wrong qualifier");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_insert_returning_respects_target_alias() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO),
alias: None,
}],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx)
.expect("INSERT RETURNING should resolve the target alias");
}
// === Test 11: SELECT with LIMIT ===
#[test]
fn test_codegen_select_with_limit() {
let stmt = star_select_with_limit("t", 10);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should contain Integer (for limit), DecrJumpZero (for countdown).
assert!(has_opcodes(
&prog,
&[
Opcode::Integer,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::ResultRow,
Opcode::DecrJumpZero,
Opcode::Next,
Opcode::Close,
Opcode::Halt,
]
));
// DecrJumpZero p1 should be the limit register.
let djz = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::DecrJumpZero)
.expect("must have DecrJumpZero");
assert!(djz.p1 >= 1, "limit register must be allocated");
}
// === Test 12: SELECT with LIMIT and OFFSET ===
#[test]
fn test_codegen_select_with_limit_offset() {
let stmt = star_select_with_limit_offset("t", 5, 3);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have both IfPos (offset skip) and DecrJumpZero (limit).
assert!(has_opcodes(
&prog,
&[
Opcode::Integer, // limit value
Opcode::Integer, // offset value
Opcode::OpenRead,
Opcode::Rewind,
Opcode::IfPos, // offset countdown
Opcode::Column,
Opcode::ResultRow,
Opcode::DecrJumpZero, // limit countdown
Opcode::Next,
Opcode::Close,
Opcode::Halt,
]
));
// Verify IfPos p3 == 1 (decrement by 1).
let ifpos = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::IfPos)
.expect("must have IfPos");
assert_eq!(ifpos.p3, 1, "IfPos should decrement offset by 1");
}
// === Test 13: SELECT without LIMIT has no DecrJumpZero ===
#[test]
fn test_codegen_select_no_limit_no_decr() {
let stmt = star_select("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Without LIMIT, there should be no DecrJumpZero.
let djz_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::DecrJumpZero)
.count();
assert_eq!(djz_count, 0, "no DecrJumpZero without LIMIT");
// And no IfPos either.
let ifpos_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::IfPos)
.count();
assert_eq!(ifpos_count, 0, "no IfPos without OFFSET");
}
// === Test 14: LIMIT labels properly resolved ===
#[test]
fn test_codegen_select_limit_labels_resolved() {
let stmt = star_select_with_limit_offset("t", 10, 5);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// All jump targets should be valid addresses.
for op in prog.ops() {
if op.opcode.is_jump() {
assert!(
op.p2 >= 0,
"unresolved jump at {:?}: p2 = {}",
op.opcode,
op.p2
);
assert!(
usize::try_from(op.p2).unwrap() <= prog.len(),
"jump target out of range at {:?}: p2 = {} (prog len = {})",
op.opcode,
op.p2,
prog.len()
);
}
}
}
// ── ORDER BY test helpers ──
fn star_select_order_by(table: &str, col: &str, desc: bool) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare(col), Span::ZERO),
direction: if desc {
Some(SortDirection::Desc)
} else {
None
},
nulls: None,
}],
limit: None,
}
}
fn select_col_order_by(
table: &str,
select_col: &str,
order_col: &str,
desc: bool,
) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare(select_col), Span::ZERO),
alias: None,
}],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare(order_col), Span::ZERO),
direction: if desc {
Some(SortDirection::Desc)
} else {
None
},
nulls: None,
}],
limit: None,
}
}
fn star_select_order_by_with_limit(table: &str, col: &str, limit: i64) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare(col), Span::ZERO),
direction: None,
nulls: None,
}],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(limit), Span::ZERO),
offset: None,
}),
}
}
fn star_select_order_by_expr(table: &str, expr: Expr) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr,
direction: None,
nulls: None,
}],
limit: None,
}
}
// === Test 15: SELECT with ORDER BY ===
#[test]
fn test_codegen_select_order_by() {
let stmt = star_select_order_by("t", "a", false);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Two-pass pattern: SorterOpen, OpenRead, scan loop, SorterSort, output loop.
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::MakeRecord,
Opcode::SorterInsert,
Opcode::Next,
Opcode::Close,
Opcode::SorterSort,
Opcode::SorterData,
Opcode::Column,
Opcode::ResultRow,
Opcode::SorterNext,
Opcode::Close,
Opcode::Halt,
]
));
// SorterOpen p2 should be 1 (one sort key column).
let so = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.unwrap();
assert_eq!(so.p2, 1, "SorterOpen should have 1 key column");
}
// === Test 16: SELECT ORDER BY DESC ===
#[test]
fn test_codegen_select_order_by_desc() {
let stmt = star_select_order_by("t", "b", true);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have SorterOpen with sort order in P4.
let so = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.unwrap();
assert_eq!(so.p2, 1, "SorterOpen should have 1 key column");
// P4 should contain the '-' (DESC) sort order.
assert!(
matches!(&so.p4, P4::Str(s) if s == "-"),
"SorterOpen P4 should be '-' for DESC, got {:?}",
so.p4
);
}
#[test]
fn test_codegen_select_order_by_uses_index_without_sorter() {
let stmt = star_select_order_by("t", "b", false);
let schema = test_schema_with_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// ORDER BY on indexed column should stream via index cursor.
assert!(has_opcodes(
&prog,
&[
Opcode::OpenRead, // table
Opcode::OpenRead, // index
Opcode::Rewind,
Opcode::IdxRowid,
Opcode::SeekRowid,
Opcode::ResultRow,
Opcode::Next,
Opcode::Halt,
]
));
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"expected index cursor open for ORDER BY optimization"
);
let sorter_count = prog
.ops()
.iter()
.filter(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
})
.count();
assert_eq!(
sorter_count, 0,
"index-assisted ORDER BY should bypass sorter"
);
}
#[test]
fn test_codegen_order_by_repeated_equality_prefix_uses_composite_range_seek() {
let stmt = select_sql(
"SELECT m.idx, COALESCE(LENGTH(CAST(m.content AS BLOB)), 0) \
FROM messages AS m \
WHERE m.conversation_id = ?1 AND m.idx > ?2 \
ORDER BY m.conversation_id, m.idx \
LIMIT ?3",
);
let schema = test_schema_with_composite_prefix_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "sqlite_autoindex_messages_1")
}),
"repeated fixed ORDER BY prefix should retain the composite index: {:?}",
prog.ops()
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"composite index plan should seek directly to the requested range: {:?}",
prog.ops()
);
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"repeated equality-constrained ORDER BY prefix must not force a temp sorter: {:?}",
prog.ops()
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::DecrJumpZero),
"parameterized LIMIT should stop the streaming index scan"
);
}
#[test]
fn test_codegen_order_by_repeated_equality_prefix_without_range_uses_bounded_index() {
let stmt = select_sql(
"SELECT m.idx FROM messages AS m \
WHERE m.conversation_id = ?1 \
ORDER BY m.conversation_id, m.idx \
LIMIT ?2",
);
let schema = test_schema_with_composite_prefix_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"fixed prefix should anchor the ordered index scan: {:?}",
prog.ops()
);
assert!(
!prog
.ops()
.iter()
.any(|op| { matches!(op.opcode, Opcode::Rewind | Opcode::SorterOpen) }),
"fixed-prefix ORDER BY should neither rewind the full index nor sort: {:?}",
prog.ops()
);
}
#[test]
fn test_codegen_order_by_fixed_prefix_direction_does_not_constrain_suffix() {
let stmt = select_sql(
"SELECT m.idx FROM messages AS m \
WHERE m.conversation_id = ?1 \
ORDER BY m.conversation_id DESC, m.idx ASC",
);
let schema = test_schema_with_composite_prefix_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"direction on a fixed value must not reject the ascending suffix"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"fixed-prefix direction should be elided before direction matching"
);
}
#[test]
fn test_codegen_order_by_multiple_repeated_equality_prefixes_uses_index() {
let stmt = select_sql(
"SELECT e.sequence FROM events AS e \
WHERE e.tenant_id = ?1 AND e.stream_id = ?2 \
ORDER BY e.tenant_id, e.stream_id, e.sequence \
LIMIT ?3",
);
let schema = test_schema_with_three_column_composite_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let prefix_boundary = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::IdxGT)
.expect("two-column equality prefix should bound the index walk");
assert_eq!(prefix_boundary.p5, 2);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"both fixed ORDER BY terms should be removed before matching sequence"
);
}
#[test]
fn test_codegen_order_by_repeated_prefix_desc_range_uses_reverse_seek() {
let stmt = select_sql(
"SELECT m.idx FROM messages AS m \
WHERE m.conversation_id = ?1 AND m.idx < ?2 \
ORDER BY m.conversation_id DESC, m.idx DESC \
LIMIT ?3",
);
let schema = test_schema_with_composite_prefix_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::SeekLE | Opcode::SeekLT)),
"DESC suffix should use the dedicated reverse composite seek: {:?}",
prog.ops()
);
assert!(prog.ops().iter().any(|op| op.opcode == Opcode::Prev));
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"fixed DESC prefix should not force a sorter"
);
}
#[test]
fn test_codegen_order_by_prefix_elision_requires_matching_collation() {
let compatible = select_sql(
"SELECT sequence FROM collated_events \
WHERE tenant = ?1 \
ORDER BY tenant, sequence",
);
let incompatible = select_sql(
"SELECT sequence FROM collated_events \
WHERE tenant = ?1 \
ORDER BY tenant COLLATE BINARY, sequence",
);
let schema = test_schema_with_collated_composite_prefix_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut compatible_builder = ProgramBuilder::new();
codegen_select(&mut compatible_builder, &compatible, &schema, &ctx).unwrap();
let compatible_program = compatible_builder.finish().unwrap();
assert!(
!compatible_program
.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterOpen),
"matching NOCASE semantics should permit prefix elision"
);
let mut incompatible_builder = ProgramBuilder::new();
codegen_select(&mut incompatible_builder, &incompatible, &schema, &ctx).unwrap();
let incompatible_program = incompatible_builder.finish().unwrap();
assert!(
incompatible_program
.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterOpen),
"BINARY ORDER BY must not be treated as constant under NOCASE equality"
);
}
#[test]
fn test_resolve_order_by_prefix_does_not_borrow_equality_from_other_scope() {
let stmt = select_sql(
"SELECT m.idx FROM messages AS m \
WHERE outer_scope.conversation_id = ?1 \
ORDER BY m.idx",
);
let SelectCore::Select {
columns,
where_clause,
..
} = &stmt.body.select
else {
unreachable!("test query should parse as a SELECT core");
};
let schema = test_schema_with_composite_prefix_index();
assert!(
resolve_order_by_index_plan(
&schema[0],
Some("m"),
columns,
where_clause.as_deref(),
&stmt.order_by,
Distinctness::All,
)
.is_none(),
"a qualified equality from another scope must not pin this index prefix"
);
}
#[test]
fn test_codegen_select_order_by_desc_uses_index_reverse_scan_without_sorter() {
let stmt = star_select_order_by("t", "b", true);
let schema = test_schema_with_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::OpenRead, // table
Opcode::OpenRead, // index
Opcode::Last,
Opcode::IdxRowid,
Opcode::SeekRowid,
Opcode::ResultRow,
Opcode::Prev,
Opcode::Halt,
]
));
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"descending index-assisted ORDER BY should bypass sorter"
);
}
#[test]
fn test_resolve_sort_key_prefers_result_alias_named_rowid_over_visible_column() {
let order_by_expr = Expr::Column(ColumnRef::bare("rowid"), Span::ZERO);
let columns = vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: Some("rowid".to_owned()),
}];
let schema = schema_with_visible_rowid_column_and_a_indexes();
assert!(matches!(
resolve_sort_key(&order_by_expr, &schema[0], None, &columns),
SortKeySource::Column(0)
));
}
#[test]
fn test_codegen_select_order_by_alias_named_rowid_uses_sorter_not_hidden_rowid_scan() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: Some("rowid".to_owned()),
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("rowid"), Span::ZERO),
direction: None,
nulls: None,
}],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"ORDER BY alias named rowid should sort by the output expression, not stream hidden rowid order"
);
}
#[test]
fn test_codegen_select_order_by_alias_named_rowid_prefers_alias_index() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: Some("rowid".to_owned()),
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("rowid"), Span::ZERO),
direction: None,
nulls: None,
}],
limit: None,
};
let schema = schema_with_visible_rowid_column_and_a_indexes();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_a")),
"ORDER BY alias should use the aliased expression's index"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_rowid")),
"visible rowid column index must not override SELECT-list alias precedence"
);
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"aliased-column index ORDER BY should bypass the sorter"
);
}
#[test]
fn test_codegen_select_order_by_wrong_qualifier_errors() {
let mut stmt = simple_select(&["a"], "t", None);
stmt.order_by = vec![OrderingTerm {
expr: Expr::Column(ColumnRef::qualified("u", "b"), Span::ZERO),
direction: None,
nulls: None,
}];
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("mismatched ORDER BY qualifier should be a semantic error");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.b"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_select_order_by_alias_hides_base_table_qualifier() {
let mut stmt = simple_select(&["a"], "t", None);
if let SelectCore::Select { from, .. } = &mut stmt.body.select {
*from = Some(from_table_as("t", "x"));
}
stmt.order_by = vec![OrderingTerm {
expr: Expr::Column(ColumnRef::qualified("t", "b"), Span::ZERO),
direction: None,
nulls: None,
}];
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("base-table qualifier should not resolve after FROM aliasing");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.b"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_select_covering_order_by_skips_table_lookup() {
let stmt = select_col_order_by("t", "b", "b", false);
let schema = test_schema_with_index();
let ctx = CodegenContext {
index_ordered_scan_reliable: true,
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let table_open_count = prog
.ops()
.iter()
.filter(|op| {
op.opcode == Opcode::OpenRead && matches!(&op.p4, P4::Table(name) if name == "t")
})
.count();
assert_eq!(
table_open_count, 0,
"covering ORDER BY path should not open table cursor"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::OpenRead
&& matches!(&op.p4, P4::Index(name) if name == "idx_t_b")),
"covering ORDER BY path should open index cursor"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"covering ORDER BY path should not perform table rowid lookups"
);
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"covering ORDER BY path should bypass sorter"
);
}
// === Test 17: SELECT ORDER BY + LIMIT ===
#[test]
fn test_codegen_select_order_by_with_limit() {
let stmt = star_select_order_by_with_limit("t", "a", 5);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have SorterSort + DecrJumpZero (LIMIT on sorted output).
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::SorterSort,
Opcode::SorterData,
Opcode::ResultRow,
Opcode::DecrJumpZero,
Opcode::SorterNext,
]
));
// LIMIT is evaluated before opening or scanning the source table.
let integers: Vec<_> = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Integer)
.collect();
assert!(
integers.iter().any(|op| op.p1 == 5),
"should have Integer with limit value 5"
);
let sorter_open = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ORDER BY + LIMIT should open a sorter");
assert_eq!(
sorter_open.p5, SORTER_OPEN_TOP_N_REGISTER,
"simple ORDER BY + LIMIT should read its top-N bound from a register"
);
assert!(
integers
.iter()
.any(|op| op.p1 == 5 && op.p2 == sorter_open.p3),
"SorterOpen.p3 should name the register containing LIMIT 5"
);
let limit_index = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::Integer && op.p1 == 5)
.expect("constant LIMIT should be emitted");
let source_open_index = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::OpenRead)
.expect("ordered SELECT should open its source");
assert!(
limit_index < source_open_index,
"LIMIT evaluation must precede source-table I/O"
);
}
#[test]
fn test_codegen_ordered_top_n_preflights_before_independent_projection() {
let stmt = select_sql("SELECT out(b) FROM t ORDER BY key(a) LIMIT ?1 OFFSET ?2");
let schema = test_schema();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
let function_index = |name: &str| {
ops.iter()
.position(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(actual) if actual == name)
})
.unwrap_or_else(|| {
assert!(test_failure(), "expected {name} function opcode");
usize::MAX
})
};
let preflight_index = ops
.iter()
.position(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
})
.expect("dynamic top-N should emit candidate preflight");
assert!(
function_index("KEY") < preflight_index && preflight_index < function_index("OUT"),
"candidate admission must occur after the ORDER key and before an independent projection"
);
let sorter_open = ops
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered SELECT should open a sorter");
assert_eq!(sorter_open.p5, SORTER_OPEN_TOP_N_REGISTER);
assert!(
ops.iter()
.any(|op| { op.opcode == Opcode::OffsetLimit && op.p3 == sorter_open.p3 }),
"runtime LIMIT+OFFSET should supply the sorter bound"
);
let sorter_open_index = ops
.iter()
.position(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered SELECT should open a sorter");
let second_must_be_int = ops
.iter()
.enumerate()
.filter(|(_, op)| op.opcode == Opcode::MustBeInt)
.nth(1)
.map(|(index, _)| index)
.expect("LIMIT and OFFSET should both be coerced");
assert!(
second_must_be_int < sorter_open_index,
"LIMIT/OFFSET coercion must finish before sorter/source setup"
);
}
#[test]
fn test_codegen_ordered_raw_non_numbered_parameters_fail_closed() {
let schema = test_schema();
for sql in [
"SELECT ? AS x FROM t ORDER BY x LIMIT ?",
"SELECT :shared AS x FROM t ORDER BY x LIMIT :shared",
"SELECT a FROM t WHERE a = ? ORDER BY a LIMIT ?",
"SELECT a FROM t ORDER BY (? + a) LIMIT ?",
] {
let stmt = select_sql(sql);
let mut b = ProgramBuilder::new();
let error = codegen_select(&mut b, &stmt, &schema, &CodegenContext::default())
.expect_err("emission-reordered raw parameters should fail closed");
assert!(
matches!(
error,
CodegenError::Unsupported(ref message)
if message.contains("canonical numbered bind parameters")
),
"unexpected raw ordered-parameter error for `{sql}`: {error:?}"
);
}
let stmt = select_sql("SELECT ?1 AS x FROM t ORDER BY x LIMIT ?2 OFFSET ?3");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default())
.expect("canonical ?NNN slots remain stable despite LIMIT-first emission");
let program = b.finish().unwrap();
let variable_slots = program
.ops()
.iter()
.filter_map(|op| (op.opcode == Opcode::Variable).then_some(op.p1))
.collect::<Vec<_>>();
assert_eq!(
variable_slots,
vec![2, 3, 1],
"LIMIT/OFFSET may emit before projection, but explicit bind slots must remain textual"
);
}
#[test]
fn test_codegen_ordered_distinct_top_n_membership_precedes_admission() {
let assert_program = |ops: &[VdbeOp], label: &str| {
let Some(sorter_open) = ops.iter().find(|op| op.opcode == Opcode::SorterOpen) else {
assert!(
test_failure(),
"[{label}] ordered DISTINCT should open a sorter"
);
return;
};
assert_eq!(
sorter_open.p5, SORTER_OPEN_TOP_N_REGISTER,
"[{label}] ordered DISTINCT should retain only LIMIT+OFFSET representatives"
);
let Some(preflight_index) = ops.iter().position(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
}) else {
assert!(
test_failure(),
"[{label}] ordered DISTINCT should preflight admission"
);
return;
};
let Some(found_index) = ops.iter().position(|op| op.opcode == Opcode::Found) else {
assert!(test_failure(), "[{label}] DISTINCT should probe membership");
return;
};
let Some(distinct_insert_index) =
ops.iter().position(|op| op.opcode == Opcode::IdxInsert)
else {
assert!(
test_failure(),
"[{label}] DISTINCT should record first membership"
);
return;
};
assert!(
found_index < distinct_insert_index && distinct_insert_index < preflight_index,
"[{label}] every first DISTINCT representative must enter membership before \
bounded-sorter admission; rejected representatives must still suppress later duplicates"
);
let output_calls = ops
.iter()
.enumerate()
.filter_map(|(index, op)| {
(op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT"))
.then_some(index)
})
.collect::<Vec<_>>();
assert_eq!(
output_calls.len(),
1,
"[{label}] function-bearing DISTINCT output should be evaluated once per source row"
);
assert!(
output_calls[0] < found_index,
"[{label}] pass-1 output must be evaluated before DISTINCT membership"
);
let Some(offset_index) = ops.iter().position(|op| op.opcode == Opcode::IfPos) else {
assert!(
test_failure(),
"[{label}] OFFSET should skip sorted representatives"
);
return;
};
let Some(sorter_data_index) = ops.iter().position(|op| op.opcode == Opcode::SorterData)
else {
assert!(
test_failure(),
"[{label}] pass 2 should read retained sorter rows"
);
return;
};
assert!(
output_calls[0] < offset_index && offset_index < sorter_data_index,
"[{label}] OFFSET must skip representatives before reading stored output"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekRowid),
"[{label}] stored function output must not seek and reproject a source row"
);
};
let schema = test_schema();
let stmt = select_sql("SELECT DISTINCT out(b) AS x FROM t ORDER BY x LIMIT 1 OFFSET 1");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let program = b.finish().unwrap();
assert_program(program.ops(), "top-level");
let complex_ops = in_subquery_program_ops(
"SELECT a IN \
(SELECT DISTINCT out(b) AS x FROM s ORDER BY x LIMIT 1 OFFSET 1) FROM t",
&test_schema_with_subquery_source(),
);
assert_program(&complex_ops, "complex-in");
}
#[test]
fn test_codegen_ordered_exact_output_reference_is_evaluated_once() {
let schema = test_schema();
for sql in [
"SELECT out(b) AS x FROM t ORDER BY x LIMIT 1",
"SELECT out(b) AS x FROM t ORDER BY x COLLATE BINARY LIMIT 1",
"SELECT out(b) FROM t ORDER BY 1 LIMIT 1",
"SELECT out(b) FROM t ORDER BY out(t.b) LIMIT 1",
] {
let stmt = select_sql(sql);
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let output_calls = prog
.ops()
.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count();
assert_eq!(
output_calls, 1,
"an exact output alias/ordinal/expression should share one emitted value: `{sql}`"
);
}
let stmt = select_sql("SELECT out(b) FROM t ORDER BY out(b) COLLATE BINARY LIMIT 1");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let output_calls = prog
.ops()
.iter()
.filter(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "OUT")
})
.count();
assert_eq!(
output_calls, 2,
"a separately written COLLATE-wrapped expression remains a distinct evaluation"
);
}
#[test]
fn test_codegen_ordered_star_ordinal_inherits_output_collation() {
let mut schema = test_schema();
schema[0].columns[0].collation = Some("NOCASE".to_owned());
let stmt = select_sql("SELECT * FROM t ORDER BY 1 LIMIT 1");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let sorter_open = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("star ordinal should use an ordered sorter");
assert_eq!(
sorter_open.p4,
P4::Str("+|NOCASE".to_owned()),
"ORDER BY 1 should inherit the first expanded star column's collation"
);
}
#[test]
fn test_codegen_ordered_ordinals_are_range_checked_after_star_expansion() {
let schema = test_schema();
for sql in [
"SELECT a, b FROM t ORDER BY 0",
"SELECT a, b FROM t ORDER BY -1",
"SELECT a, b FROM t ORDER BY +3",
"SELECT a FROM t ORDER BY 2 COLLATE BINARY",
] {
let stmt = select_sql(sql);
let mut b = ProgramBuilder::new();
let error = codegen_select(&mut b, &stmt, &schema, &CodegenContext::default())
.expect_err("out-of-range ORDER ordinal should fail");
assert!(
matches!(error, CodegenError::Unsupported(ref message) if message.contains("ORDER BY term")),
"unexpected out-of-range ORDER ordinal error for `{sql}`: {error:?}"
);
}
let stmt = select_sql("SELECT * FROM t ORDER BY 2");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default())
.expect("ORDER BY 2 should resolve to the second column expanded from star");
}
#[test]
fn test_codegen_static_negative_limit_skips_top_n_preflight() {
let schema = test_schema();
let stmt = select_sql("SELECT out(b) FROM t ORDER BY a LIMIT -1");
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &CodegenContext::default()).unwrap();
let prog = b.finish().unwrap();
let sorter_open = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterOpen)
.expect("ordered SELECT should still use a full sorter");
assert_eq!(
sorter_open.p5, 0,
"a static negative LIMIT means no bound and should not use runtime-bound setup"
);
assert!(
!prog.ops().iter().any(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
}),
"a static negative LIMIT must not pay per-row preflight overhead"
);
let complex_ops = in_subquery_program_ops(
"SELECT a IN (SELECT out(b) FROM s ORDER BY b LIMIT -1) FROM t",
&test_schema_with_subquery_source(),
);
assert!(
!complex_ops.iter().any(|op| {
op.opcode == Opcode::SorterCompare && op.p5 == SORTER_COMPARE_TOP_N_PREFLIGHT
}),
"complex IN should also skip preflight for a static negative LIMIT"
);
}
// === Test 17b: ORDER BY arithmetic expression ===
#[test]
fn test_codegen_select_order_by_arithmetic_expression() {
let stmt = star_select_order_by_expr(
"t",
Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
span: Span::ZERO,
},
);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::OpenRead,
Opcode::Add,
Opcode::SorterInsert,
Opcode::SorterSort,
Opcode::ResultRow,
]
));
}
// === Test 17c: ORDER BY scalar function expression ===
#[test]
fn test_codegen_select_order_by_function_expression() {
let stmt = star_select_order_by_expr(
"t",
Expr::FunctionCall {
name: "length".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(ColumnRef::bare("b"), Span::ZERO)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::OpenRead,
Opcode::PureFunc,
Opcode::SorterInsert,
Opcode::SorterSort,
Opcode::ResultRow,
]
));
}
#[test]
fn test_codegen_select_where_literal_like_uses_const_fast_opcode() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("prefix%".to_owned()),
Span::ZERO,
)),
escape: None,
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::LikeConstFast),
"literal LIKE should use LikeConstFast opcode"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::PureFunc),
"literal LIKE fast path should bypass generic PureFunc dispatch"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::LikeConstFast
&& op.p4 == P4::Str("prefix".to_owned())),
"prefix LIKE should hoist the trimmed literal into LikeConstFast"
);
}
#[test]
fn test_codegen_select_custom_like_context_disables_builtin_shortcuts() {
let stmt = simple_select(
&["a"],
"t",
Some(Box::new(Expr::Like {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
pattern: Box::new(Expr::Literal(
Literal::String("123%".to_owned()),
Span::ZERO,
)),
escape: None,
op: fsqlite_ast::LikeOp::Like,
not: false,
span: Span::ZERO,
})),
);
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
with_connection_function_context(Vec::new(), false, true, || {
codegen_select(&mut b, &stmt, &schema, &ctx)
})
.unwrap();
let prog = b.finish().unwrap();
let ops = prog.ops();
assert!(
!ops.iter().any(|op| op.opcode == Opcode::LikeConstFast),
"a replaced like() function must not be bypassed by LikeConstFast"
);
assert!(
ops.iter().any(|op| {
op.opcode == Opcode::PureFunc
&& matches!(&op.p4, P4::FuncName(name) if name == "LIKE")
}),
"LIKE must dispatch through the function registry after replacement"
);
assert!(
!ops.iter().any(|op| op.opcode == Opcode::SeekGE),
"a built-in LIKE prefix range can exclude rows accepted by a replacement"
);
assert!(
ops.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"the replacement-aware lowering should retain the complete table candidate set"
);
assert!(
use_builtin_like_glob_semantics(),
"the connection function context must restore its prior state"
);
}
#[test]
fn scalar_override_declines_only_function_bearing_partial_index_heuristics() {
fn fixture_where(sql: &str) -> Expr {
let stmt = select_sql(sql);
let SelectCore::Select {
where_clause: Some(where_clause),
..
} = stmt.body.select
else {
assert!(
test_failure(),
"partial-index fixture must have a WHERE clause"
);
return expr_sql("0");
};
*where_clause
}
let mut function_table = bd_2dgf5_table();
function_table
.indexes
.first_mut()
.expect("fixture must have an index")
.where_clause = Some("abs(v) = 1".to_owned());
let function_where = fixture_where("SELECT id FROM t WHERE k = 2 AND abs(v) = 1");
let mut function_conjuncts = Vec::new();
collect_conjunctive_terms(&function_where, &mut function_conjuncts);
assert!(
index_partial_predicate_is_covered_by_query_conjuncts(
&function_table.indexes[0],
&function_conjuncts,
&function_table,
None,
),
"with built-in scalar semantics, the structurally identical function predicate \
should prove partial-index coverage"
);
assert!(
aggregate_index_prefix_literal_residual_target(
Some(&function_where),
&function_table,
None,
None,
)
.is_some(),
"fixture must exercise the partial-index equality-prefix residual heuristic"
);
let mut plain_table = bd_2dgf5_table();
plain_table
.indexes
.first_mut()
.expect("fixture must have an index")
.where_clause = Some("v = 'x'".to_owned());
let plain_where = fixture_where("SELECT id FROM t WHERE k = 2 AND v = 'x'");
let mut plain_conjuncts = Vec::new();
collect_conjunctive_terms(&plain_where, &mut plain_conjuncts);
with_connection_function_context(Vec::new(), true, false, || {
assert!(
!index_partial_predicate_is_covered_by_query_conjuncts(
&function_table.indexes[0],
&function_conjuncts,
&function_table,
None,
),
"any scalar replacement must decline a function-bearing partial-index proof"
);
assert!(
aggregate_index_prefix_literal_residual_target(
Some(&function_where),
&function_table,
None,
None,
)
.is_none(),
"a scalar replacement must keep the function-bearing partial index \
out of the equality-prefix residual heuristic"
);
assert!(
index_partial_predicate_is_covered_by_query_conjuncts(
&plain_table.indexes[0],
&plain_conjuncts,
&plain_table,
None,
),
"an unrelated scalar replacement must not disable a function-free partial predicate"
);
assert!(
aggregate_index_prefix_literal_residual_target(
Some(&plain_where),
&plain_table,
None,
None,
)
.is_some(),
"the function-free partial index must remain eligible for the residual heuristic"
);
});
assert!(
use_builtin_scalar_function_semantics(),
"the connection function context must restore scalar semantics state"
);
}
// === Test 18: ORDER BY no sorter without ORDER BY ===
#[test]
fn test_codegen_select_no_order_by_no_sorter() {
let stmt = star_select("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Without ORDER BY, there should be no sorter opcodes.
let sorter_count = prog
.ops()
.iter()
.filter(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
})
.count();
assert_eq!(sorter_count, 0, "no sorter opcodes without ORDER BY");
}
// === Test 19: ORDER BY labels properly resolved ===
#[test]
fn test_codegen_select_order_by_labels_resolved() {
let stmt = star_select_order_by("t", "a", false);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// All jump targets should be valid addresses.
for op in prog.ops() {
if op.opcode.is_jump() {
assert!(
op.p2 >= 0,
"unresolved jump at {:?}: p2 = {}",
op.opcode,
op.p2
);
assert!(
usize::try_from(op.p2).unwrap() <= prog.len(),
"jump target out of range at {:?}: p2 = {} (prog len = {})",
op.opcode,
op.p2,
prog.len()
);
}
}
// SorterNext p2 should point to SorterData (within bounds).
let sn = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::SorterNext)
.unwrap();
let target_index = usize::try_from(sn.p2).unwrap();
let target_op = &prog.ops()[target_index];
assert_eq!(
target_op.opcode,
Opcode::SorterData,
"SorterNext should jump back to SorterData"
);
}
// === Test: SELECT ORDER BY expression (a + 1) ===
#[test]
fn test_codegen_select_order_by_expression() {
// SELECT * FROM t ORDER BY a + 1
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Star],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: fsqlite_ast::BinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
},
direction: None,
nulls: None,
}],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should use the sorter (two-pass pattern).
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::OpenRead,
Opcode::Rewind,
Opcode::SorterInsert,
Opcode::Next,
Opcode::SorterSort,
Opcode::SorterData,
Opcode::ResultRow,
Opcode::SorterNext,
Opcode::Halt,
]
));
// The sort key is an expression, so we should see an Add opcode
// in the first pass (before SorterInsert).
let sorter_insert_idx = prog
.ops()
.iter()
.position(|op| op.opcode == Opcode::SorterInsert)
.unwrap();
let has_add_before_sorter = prog.ops()[..sorter_insert_idx]
.iter()
.any(|op| op.opcode == Opcode::Add);
assert!(
has_add_before_sorter,
"expression ORDER BY should emit Add before SorterInsert"
);
}
// ── Aggregate test helpers ──
/// Build `SELECT count(*) FROM table`.
fn agg_count_star(table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Build `SELECT count(*) FROM table WHERE rowid >= low AND rowid < high`.
fn agg_count_star_rowid_range(table: &str, low: i64, high: i64) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table(table)),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Ge,
right: Box::new(Expr::Literal(Literal::Integer(low), Span::ZERO)),
span: Span::ZERO,
}),
op: AstBinaryOp::And,
right: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Lt,
right: Box::new(Expr::Literal(Literal::Integer(high), Span::ZERO)),
span: Span::ZERO,
}),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn agg_count_star_exists_rowid_probe() -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(Expr::Exists {
subquery: Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Literal(Literal::Integer(1), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "rowid"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("t", "b"),
Span::ZERO,
)),
span: Span::ZERO,
}),
op: AstBinaryOp::And,
right: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "rowid"),
Span::ZERO,
)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(
Literal::Integer(5),
Span::ZERO,
)),
span: Span::ZERO,
}),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}),
not: false,
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Build `SELECT func(col) FROM table`.
fn agg_func_col(func: &str, col: &str, table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: func.to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::bare(col),
Span::ZERO,
)]),
distinct: false,
order_by: Vec::new(),
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Build `SELECT count(*), sum(col) FROM table`.
fn agg_count_star_and_sum(col: &str, table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::bare(col),
Span::ZERO,
)]),
distinct: false,
order_by: Vec::new(),
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Build `SELECT count(*) FROM table HAVING sum(col) > threshold`.
fn agg_count_star_having_sum_gt(col: &str, threshold: i64, table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::bare(col),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
}),
op: AstBinaryOp::Gt,
right: Box::new(Expr::Literal(Literal::Integer(threshold), Span::ZERO)),
span: Span::ZERO,
})),
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Build `SELECT count(*) FROM table HAVING count(*) > threshold`.
fn agg_count_star_having_count_gt(threshold: i64, table: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table(table)),
where_clause: None,
group_by: vec![],
having: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
}),
op: AstBinaryOp::Gt,
right: Box::new(Expr::Literal(Literal::Integer(threshold), Span::ZERO)),
span: Span::ZERO,
})),
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
// === Test 20: SELECT count(*) ===
#[test]
fn test_codegen_select_count_star() {
let stmt = agg_count_star("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// COUNT(*) now takes the direct Count fast path.
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenRead,
Opcode::Count,
Opcode::ResultRow,
Opcode::Close,
Opcode::Halt,
]
));
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"COUNT(*) fast path should bypass aggregate opcodes"
);
// ResultRow should cover 1 column.
let rr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::ResultRow)
.unwrap();
assert_eq!(rr.p2, 1, "count(*) produces 1 result column");
}
#[test]
fn test_codegen_select_count_star_rowid_range_uses_counter_loop() {
let stmt = agg_count_star_rowid_range("t", 10, 20);
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::SeekGE | Opcode::SeekGT)),
"rowid-bounded COUNT(*) should seek into range instead of rewinding from row 0"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::AddImm),
"specialized COUNT(*) loop should increment a counter register directly"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"rowid-bounded COUNT(*) should bypass generic aggregate execution"
);
}
#[test]
fn test_codegen_exists_subquery_uses_rowid_probe_when_available() {
let stmt = agg_count_star_exists_rowid_probe();
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"correlated EXISTS on inner rowid should probe directly with SeekRowid"
);
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"only the outer scan should rewind when EXISTS lowers to a direct rowid probe"
);
}
#[test]
fn test_codegen_select_uncorrelated_exists_subquery_uses_once_cached_boolean() {
let where_expr = Expr::Exists {
subquery: Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Literal(Literal::Integer(1), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"uncorrelated EXISTS subqueries should be evaluated once and cached"
);
}
#[test]
fn test_codegen_select_correlated_exists_subquery_does_not_use_once_materialization() {
let where_expr = Expr::Exists {
subquery: Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Literal(Literal::Integer(1), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("t", "a"),
Span::ZERO,
)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"correlated EXISTS subqueries must not be cached once because they depend on outer-row values"
);
}
#[test]
fn test_codegen_select_large_in_list_uses_once_materialized_autoindex() {
let values = (1..=8)
.map(|value| Expr::Literal(Literal::Integer(value), Span::ZERO))
.collect();
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::List(values),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"large IN lists should materialize their membership set once per statement"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"large IN lists should build an ephemeral autoindex"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxInsert),
"large IN lists should populate the ephemeral autoindex"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Found),
"large IN lists should probe membership via Found"
);
}
#[test]
fn test_codegen_select_large_collated_in_list_materializes_autoindex_with_collation() {
let values = (1..=8)
.map(|value| Expr::Literal(Literal::String(format!("v{value}")), Span::ZERO))
.collect();
let where_expr = Expr::In {
expr: Box::new(Expr::Collate {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
collation: "NOCASE".to_owned(),
span: Span::ZERO,
}),
set: InSet::List(values),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"collated large IN lists should still materialize once per statement"
);
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenAutoindex
&& matches!(&op.p4, P4::Collation(name) if name == "NOCASE")
}),
"materialized collated IN lists must thread their collation into OpenAutoindex"
);
}
#[test]
fn test_codegen_select_uncorrelated_in_subquery_uses_once_materialized_autoindex() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"uncorrelated IN subqueries should materialize once per statement"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"uncorrelated IN subqueries should build an ephemeral autoindex"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxInsert),
"uncorrelated IN subqueries should populate the ephemeral autoindex"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Found),
"uncorrelated IN subqueries should probe membership via Found"
);
}
#[test]
fn test_codegen_select_uncorrelated_collated_in_subquery_materializes_autoindex_with_collation()
{
let where_expr = Expr::In {
expr: Box::new(Expr::Collate {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
collation: "NOCASE".to_owned(),
span: Span::ZERO,
}),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"collated uncorrelated IN subqueries should still materialize once per statement"
);
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenAutoindex
&& matches!(&op.p4, P4::Collation(name) if name == "NOCASE")
}),
"materialized collated IN subqueries must thread their collation into OpenAutoindex"
);
}
#[test]
fn test_codegen_select_uncorrelated_in_subquery_with_overlapping_column_names_materializes_once()
{
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"local subquery columns that overlap outer column names must still materialize once"
);
}
#[test]
fn test_codegen_select_correlated_in_subquery_does_not_use_once_materialization() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("t", "a"),
Span::ZERO,
)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"correlated IN subqueries must not materialize once because they depend on outer-row values"
);
}
#[test]
fn test_codegen_select_count_star_indexed_in_list_uses_index_probe_expansion() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
set: InSet::List(
(1..=8)
.map(|value| Expr::Literal(Literal::Integer(value), Span::ZERO))
.collect(),
),
not: false,
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(where_expr)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| matches!(&op.p4, P4::Index(name) if op.opcode == Opcode::OpenRead && name == "idx_t_b")),
"count(*) with indexed IN-list should open the outer index"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"count(*) with indexed IN-list should build a deduplicated probe set"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"count(*) with indexed IN-list should seek into the outer index per probe value"
);
assert!(
!prog.ops().iter().any(|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "t")),
"count(*) with indexed IN-list should not fall back to opening the base table"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"count(*) with indexed IN-list should stay on the direct counter path"
);
}
#[test]
fn test_codegen_select_count_star_indexed_collated_in_list_threads_nocase() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("name"), Span::ZERO)),
set: InSet::List(
[
"alpha", "ALPHA", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
]
.into_iter()
.map(|value| Expr::Literal(Literal::String(value.to_owned()), Span::ZERO))
.collect(),
),
not: false,
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(where_expr)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_nocase_text_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::OpenAutoindex
&& matches!(op.p4, P4::Collation(ref name) if name == "NOCASE")
}),
"collated COUNT(*) IN fast path must materialize the probe set with NOCASE semantics"
);
assert!(
prog.ops().iter().any(|op| {
op.opcode == Opcode::Ne
&& matches!(op.p4, P4::Collation(ref name) if name == "NOCASE")
}),
"collated COUNT(*) IN fast path must compare probe keys against the outer index with NOCASE semantics"
);
}
#[test]
fn test_codegen_select_count_star_indexed_in_subquery_uses_index_probe_expansion() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(where_expr)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index_and_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| matches!(&op.p4, P4::Index(name) if op.opcode == Opcode::OpenRead && name == "idx_t_b")),
"count(*) with indexed IN-subquery should open the outer index"
);
assert!(
prog.ops().iter().any(|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "s")),
"count(*) with indexed IN-subquery should still read the inner probe source once"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"count(*) with indexed IN-subquery should materialize a deduplicated RHS probe set once"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"count(*) with indexed IN-subquery should seek into the outer index per probe value"
);
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::CountIndexEqRun),
"non-rowid IN-subquery should stay on per-probe indexed seeks"
);
assert!(
!prog.ops().iter().any(|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "t")),
"count(*) with indexed IN-subquery should avoid reopening the base table"
);
}
/// bd-g5ys1: the non-aggregate indexed IN-subquery must drive the outer
/// index with per-value seeks instead of full-scanning the table with a
/// per-row membership probe.
#[test]
fn bd_g5ys1_nonaggregate_indexed_in_subquery_seeks_per_distinct_value() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["b"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_index_and_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| matches!(&op.p4, P4::Index(name) if op.opcode == Opcode::OpenRead && name == "idx_t_b")),
"indexed IN-subquery should open the outer index"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"indexed IN-subquery should materialize the RHS probe set once"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Found),
"the probe-set build must dedup via Found so duplicate RHS values \
cannot emit duplicate outer rows"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"indexed IN-subquery should seek into the outer index per probe value"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxRowid)
&& prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"each index hit should look the row up in the base table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::ResultRow),
"matching rows should be emitted"
);
// The outer table is opened for row lookups (cursor 0) but never
// scanned: every Rewind belongs to the probe/source cursors.
assert!(
prog.ops().iter().any(|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "t")),
"the base table stays open for SeekRowid lookups"
);
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::Rewind && op.p1 == 0),
"the outer table must not be full-scanned, got: {:?}",
prog.ops().iter().map(|op| op.opcode).collect::<Vec<_>>()
);
}
#[test]
fn test_codegen_select_count_star_indexed_in_rowid_subquery_uses_bounded_source_scan() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("rowid"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(where_expr)),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_index_and_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.all(|op| op.opcode != Opcode::OpenAutoindex),
"rowid-driven IN-subquery should scan the unique rowid probe source directly"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::CountIndexEqRun),
"rowid-driven IN-subquery should consume the materialized RHS via duplicate-run counting"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Ge | Opcode::Gt)),
"rowid-driven IN-subquery should bound the inner probe-source rowid scan"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Rowid),
"rowid-driven IN-subquery should read source rowids for bounded stop checks"
);
let rowid_reg = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Rowid)
.expect("rowid-backed probe should read the source rowid")
.p2;
let count_eq_run = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::CountIndexEqRun)
.expect("rowid-backed probe should count duplicate index-key runs");
assert_eq!(
count_eq_run.p3, rowid_reg,
"CountIndexEqRun should consume the source rowid register directly"
);
assert!(
prog.ops().iter().all(|op| op.opcode != Opcode::Copy),
"rowid-backed indexed IN count should not copy the rowid into a separate probe register"
);
}
#[test]
fn test_extract_count_indexed_in_target_uses_direct_probe_source_for_bounded_ipk_subquery() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: Some(Box::new(Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(
ColumnRef::qualified("s", "id"),
Span::ZERO,
),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "id"),
Span::ZERO,
)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(
Literal::Integer(5),
Span::ZERO,
)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'd', false),
],
indexes: vec![IndexSchema {
name: "idx_t_b".to_owned(),
root_page: 4,
columns: vec!["b".to_owned()],
key_expressions: vec!["b".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'd', true),
ColumnInfo::basic("name", 'a', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
];
let table = find_table(&schema, "t").expect("outer table should exist");
let scan_ctx = ScanCtx {
cursor: 0,
table,
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
let SelectCore::Select {
where_clause: Some(where_clause),
..
} = &stmt.body.select
else {
unreachable!("fixture should include a WHERE clause");
};
let extracted = extract_count_indexed_in_target(
Some(where_clause),
table,
None,
&schema,
&scan_ctx,
None,
)
.expect("indexed IN target should match");
assert_eq!(extracted.0.name, "idx_t_b");
match extracted.1 {
CountIndexedInTarget::ProbeSource(probe_source) => {
assert!(matches!(probe_source.value, InProbeValue::Rowid));
}
CountIndexedInTarget::MaterializedProbeSource(_) => {
unreachable!(
"bounded IPK-projected IN target should lower to a direct rowid probe source"
);
}
CountIndexedInTarget::List(_) => {
unreachable!("IPK-projected IN target should lower to a rowid probe source");
}
}
}
#[test]
fn test_extract_count_indexed_in_target_rejects_collation_mismatch() {
let where_expr = Expr::In {
expr: Box::new(Expr::Collate {
expr: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
collation: "NOCASE".to_owned(),
span: Span::ZERO,
}),
set: InSet::List(
[
"alpha", "ALPHA", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
]
.into_iter()
.map(|value| Expr::Literal(Literal::String(value.to_owned()), Span::ZERO))
.collect(),
),
not: false,
span: Span::ZERO,
};
let schema = test_schema_with_index();
let table = find_table(&schema, "t").expect("table should exist");
let scan_ctx = ScanCtx {
cursor: 0,
table,
table_alias: None,
schema: Some(&schema),
register_base: None,
secondaries: &[],
};
assert!(
extract_count_indexed_in_target(
Some(&where_expr),
table,
None,
&schema,
&scan_ctx,
None,
)
.is_none(),
"count(*) indexed-IN fast path must reject indexes whose collation does not match the probe semantics"
);
}
#[test]
fn test_codegen_select_count_star_exists_semijoin_uses_duplicate_run_count_opcode() {
let stmt = agg_count_star_exists_rowid_probe();
let schema = test_schema_with_index_and_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(
|op| matches!(&op.p4, P4::Index(name) if op.opcode == Opcode::OpenRead && name == "idx_t_b")
),
"count(*) EXISTS semijoin fast path should open the outer index"
);
assert!(
prog.ops().iter().any(
|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "s")
),
"count(*) EXISTS semijoin fast path should still read the inner probe source once"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Ge | Opcode::Gt | Opcode::SeekGE)),
"count(*) EXISTS semijoin fast path should keep the bounded source scan and indexed outer probe"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Rowid),
"count(*) EXISTS semijoin fast path should read source rowids for bounded stop checks"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::CountIndexEqRun),
"count(*) EXISTS semijoin fast path should fuse duplicate-run counting into a dedicated opcode"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::Eq),
"count(*) EXISTS semijoin fast path should avoid interpreter-level duplicate-run equality loops"
);
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"count(*) EXISTS semijoin fast path should stay off temp materialization"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Table(name) if op.opcode == Opcode::OpenRead && name == "t")),
"count(*) EXISTS semijoin fast path should avoid reopening the outer base table"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"count(*) EXISTS semijoin fast path should stay on the direct counter path"
);
}
#[test]
fn test_codegen_single_inner_join_uses_index_lookup_plan() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("o", "amount"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("o", "customer_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("c", "id"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"lookup join should only rewind the outer table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"lookup join should seek into the right-side index"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxRowid),
"lookup join should extract matching rowids from the right-side index"
);
}
#[test]
fn test_codegen_single_left_join_uses_index_lookup_plan() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("o", "amount"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Left,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("o", "customer_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("c", "id"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"lookup left join should only rewind the outer table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"left join should seek into the right-side index"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::NullRow),
"left join should still null-extend unmatched right rows"
);
}
#[test]
fn test_codegen_single_inner_join_uses_rowid_lookup_plan() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("o", "amount"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("o", "customer_id"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"rowid lookup join should only rewind the outer table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"join against an INTEGER PRIMARY KEY should seek by rowid"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"rowid lookup join should not use a secondary-index probe"
);
}
/// Schema matching the bd_zjisk issue #62 bounded repro:
/// messages (large fact) → conversations → agents (INNER) + workspaces (LEFT)
/// Every foreign-key edge lands on an INTEGER PRIMARY KEY, so the
/// multi-join lookup fast path should fire for all three joins.
fn test_schema_issue_62_fts_rebuild() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
ColumnInfo::basic("content", 'B', false),
ColumnInfo::basic("created_at", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("agent_id", 'D', false),
ColumnInfo::basic("workspace_id", 'D', false),
ColumnInfo::basic("title", 'B', false),
ColumnInfo::basic("source_path", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("slug", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "workspaces".to_owned(),
root_page: 5,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("path", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
/// Build the 4-table SELECT from the issue #62 repro:
///
/// SELECT m.id, c.title, a.slug, w.path
/// FROM messages m
/// JOIN conversations c ON m.conversation_id = c.id
/// JOIN agents a ON c.agent_id = a.id
/// LEFT JOIN workspaces w ON c.workspace_id = w.id;
fn issue_62_four_table_select() -> SelectStatement {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "agent_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("a", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_w = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "workspace_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("w", "id"), Span::ZERO)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "title"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "slug"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("w", "path"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Left,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("workspaces"),
alias: Some("w".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_w)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// The exact 4-table JOIN shape from bd_zjisk issue #62 must use the
/// multi-join lookup fast path — not the nested full-scan path — so
/// that execution scales linearly in the driver instead of blowing
/// up as the Cartesian product of all four tables (the reported
/// 81-minute vs 8-second gap).
#[test]
fn test_codegen_issue_62_four_table_join_uses_multi_lookup_path() {
let stmt = issue_62_four_table_select();
let schema = test_schema_issue_62_fts_rebuild();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
let seek_rowid_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::SeekRowid)
.count();
let null_row_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::NullRow)
.count();
assert_eq!(
rewind_count, 1,
"multi-join lookup fast path should only Rewind the driver \
table once; nested full scans indicate a regression to the \
Cartesian-product codegen path"
);
assert_eq!(
seek_rowid_count, 3,
"each of the three joins should resolve to a SeekRowid on \
its respective dimension table (conversations, agents, \
workspaces)"
);
assert!(
null_row_count >= 1,
"the trailing LEFT JOIN on workspaces must still emit a \
NullRow stub for probe-misses so the outer row is \
preserved with a NULL workspace"
);
}
/// Sanity check: a 3-table INNER-JOIN chain whose middle step
/// references the first (not the driver) must still resolve through
/// the multi-join lookup fast path.
#[test]
fn test_codegen_three_table_inner_chain_uses_multi_lookup_path() {
// messages → conversations (via m.conversation_id = c.id)
// → agents (via c.agent_id = a.id) ← depends on conversations, not messages
let stmt = {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "agent_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("a", "id"), Span::ZERO)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "slug"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
};
let schema = test_schema_issue_62_fts_rebuild();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
let seek_rowid_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::SeekRowid)
.count();
let null_row_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::NullRow)
.count();
assert_eq!(
rewind_count, 1,
"3-table INNER chain should only rewind the driver"
);
assert_eq!(
seek_rowid_count, 2,
"both right-side lookups should resolve to SeekRowid"
);
assert_eq!(
null_row_count, 0,
"pure INNER JOIN chain must not emit NullRow stubs"
);
}
/// A non-terminal LEFT JOIN (LEFT followed by another join) must NOT
/// take the multi-join lookup fast path, because the codegen below
/// only knows how to emit a trailing null-row block for the *last*
/// step. Today the scan fallback (`codegen_join_select`) also
/// refuses multi-table LEFT joins, so the expected outcome for this
/// shape is `CodegenError::Unsupported` bubbled out of the
/// VDBE-codegen layer and handled by the connection-level
/// interpreter. The important invariant we are asserting here is:
/// we do NOT silently return a 1-Rewind fast-path plan (which would
/// be incorrect because the middle LEFT miss would skip the
/// trailing INNER step and produce wrong rows).
#[test]
fn test_codegen_non_terminal_left_join_does_not_take_fast_path() {
// messages m
// INNER JOIN workspaces w ON m.conversation_id = w.id
// LEFT JOIN agents a ON m.conversation_id = a.id
// INNER JOIN conversations c ON m.conversation_id = c.id
//
// (the exact FK edges are contrived — we just want an INNER +
// LEFT + INNER chain over the issue-62 schema that every step
// could in principle resolve to a rowid lookup.)
let on_m_w = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("w", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_m_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("a", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("workspaces"),
alias: Some("w".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_w)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Left,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_a)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_issue_62_fts_rebuild();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let result = codegen_select(&mut b, &stmt, &schema, &ctx);
match result {
Ok(()) => {
// If codegen DOES succeed (e.g. because the scan
// fallback ever learns multi-table LEFT), the crucial
// invariant we care about is that the fast path was
// not silently taken. The fast path always emits
// exactly one Rewind; more than one Rewind means we
// landed on the nested-scan path, which is the only
// correct shape for this chain today.
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert!(
rewind_count >= 2,
"non-terminal LEFT JOIN landed on the fast path \
(only {rewind_count} Rewinds) — the resolver \
should have rejected it"
);
}
Err(CodegenError::Unsupported(_)) => {
// Expected today: the scan fallback also refuses
// multi-table LEFT, so the whole codegen layer bubbles
// up Unsupported and the statement runs through the
// connection-level interpreter.
}
Err(other) => {
unreachable!(
"non-terminal LEFT JOIN must be rejected via \
Unsupported, got {other:?}"
);
}
}
}
fn test_schema_multi_join_composite_unique_prefix_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
ColumnInfo::basic("title", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
ColumnInfo::basic("slug", 'B', false),
ColumnInfo::basic("label", 'B', false),
],
indexes: vec![IndexSchema {
name: "agents_tenant_slug_unique".to_owned(),
root_page: 5,
columns: vec!["tenant_id".to_owned(), "slug".to_owned()],
key_expressions: vec!["tenant_id".to_owned(), "slug".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn composite_unique_prefix_multi_join_stmt() -> SelectStatement {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "tenant_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("a", "tenant_id"),
Span::ZERO,
)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "label"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
#[test]
fn test_codegen_multi_join_rejects_composite_unique_prefix_lookup() {
let stmt = composite_unique_prefix_multi_join_stmt();
let schema = test_schema_multi_join_composite_unique_prefix_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert!(
rewind_count >= 2,
"joining on the leftmost column of UNIQUE(tenant_id, slug) is not a \
single-row lookup; the multi-join fast path must be rejected"
);
}
fn test_schema_multi_join_prefers_unique_single_column_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
ColumnInfo::basic("label", 'B', false),
],
indexes: vec![
IndexSchema {
name: "agents_tenant_idx".to_owned(),
root_page: 5,
columns: vec!["tenant_id".to_owned()],
key_expressions: vec!["tenant_id".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "agents_tenant_unique".to_owned(),
root_page: 6,
columns: vec!["tenant_id".to_owned()],
key_expressions: vec!["tenant_id".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn test_schema_multi_join_prefers_ascending_unique_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_id", 'D', false),
ColumnInfo::basic("label", 'B', false),
],
indexes: vec![
IndexSchema {
name: "agents_tenant_unique_desc".to_owned(),
root_page: 5,
columns: vec!["tenant_id".to_owned()],
key_expressions: vec!["tenant_id".to_owned()],
key_sort_directions: vec![SortDirection::Desc],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "agents_tenant_unique_asc".to_owned(),
root_page: 6,
columns: vec!["tenant_id".to_owned()],
key_expressions: vec!["tenant_id".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn unique_single_column_lookup_multi_join_stmt() -> SelectStatement {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "tenant_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("a", "tenant_id"),
Span::ZERO,
)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "label"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
#[test]
fn test_codegen_multi_join_uses_unique_lookup_even_if_nonunique_sibling_comes_first() {
let stmt = unique_single_column_lookup_multi_join_stmt();
let schema = test_schema_multi_join_prefers_unique_single_column_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"a qualifying single-column UNIQUE lookup should keep the multi-join fast path enabled \
even if a non-unique sibling index appears first"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"the multi-join fast path should still seek into the qualifying unique index"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxRowid),
"the multi-join fast path should fetch rowids from the qualifying unique index"
);
}
#[test]
fn test_codegen_multi_join_skips_descending_unique_lookup_sibling() {
let stmt = unique_single_column_lookup_multi_join_stmt();
let schema = test_schema_multi_join_prefers_ascending_unique_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "agents_tenant_unique_asc")),
"multi-join lookup should skip descending unique siblings and open an ascending direct-lookup index"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "agents_tenant_unique_desc")),
"multi-join lookup must not drive the fast path with a descending unique index"
);
}
fn test_schema_multi_join_prefers_collation_matching_unique_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_name", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo {
collation: Some("NOCASE".to_owned()),
..ColumnInfo::basic("tenant_name", 'B', false)
},
ColumnInfo::basic("label", 'B', false),
],
indexes: vec![
IndexSchema {
name: "agents_tenant_unique_binary".to_owned(),
root_page: 5,
columns: vec!["tenant_name".to_owned()],
key_expressions: vec!["tenant_name".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "agents_tenant_unique_nocase".to_owned(),
root_page: 6,
columns: vec!["tenant_name".to_owned()],
key_expressions: vec!["tenant_name".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![Some("NOCASE".to_owned())],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn test_schema_single_join_prefers_collation_matching_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "customers".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("name", 'B', false),
ColumnInfo::basic("region", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "orders".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo {
collation: Some("NOCASE".to_owned()),
..ColumnInfo::basic("region", 'B', false)
},
ColumnInfo::basic("amount", 'E', false),
],
indexes: vec![
IndexSchema {
name: "idx_orders_region_binary".to_owned(),
root_page: 4,
columns: vec!["region".to_owned()],
key_expressions: vec!["region".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "idx_orders_region_nocase".to_owned(),
root_page: 5,
columns: vec!["region".to_owned()],
key_expressions: vec!["region".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![Some("NOCASE".to_owned())],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn test_schema_single_join_rejects_composite_lookup_index() -> Vec<TableSchema> {
vec![
TableSchema {
name: "customers".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("name", 'B', false),
ColumnInfo::basic("region", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "orders".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("region", 'B', false),
ColumnInfo::basic("amount", 'E', false),
],
indexes: vec![IndexSchema {
name: "idx_orders_region_amount".to_owned(),
root_page: 4,
columns: vec!["region".to_owned(), "amount".to_owned()],
key_expressions: vec!["region".to_owned(), "amount".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn test_schema_single_join_prefers_ascending_lookup_index() -> Vec<TableSchema> {
vec![
TableSchema {
name: "customers".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("name", 'B', false),
ColumnInfo::basic("region", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "orders".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("region", 'B', false),
ColumnInfo::basic("amount", 'E', false),
],
indexes: vec![
IndexSchema {
name: "idx_orders_region_desc".to_owned(),
root_page: 4,
columns: vec!["region".to_owned()],
key_expressions: vec!["region".to_owned()],
key_sort_directions: vec![SortDirection::Desc],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "idx_orders_region_asc".to_owned(),
root_page: 5,
columns: vec!["region".to_owned()],
key_expressions: vec!["region".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn collation_matching_single_join_lookup_stmt() -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("o", "amount"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "region"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("o", "region"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn collation_matching_grouped_join_lookup_stmt() -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::qualified("o", "amount"),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("customers"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("orders"),
alias: Some("o".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "region"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("o", "region"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![Expr::Column(ColumnRef::qualified("c", "name"), Span::ZERO)],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
fn collation_matching_unique_single_column_lookup_multi_join_stmt() -> SelectStatement {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "tenant_name"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("a", "tenant_name"),
Span::ZERO,
)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "label"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
/// Oracle reconciliation (sqlite3 3.46.1): a comparison `A = B` takes its
/// collation from the LEFT operand, so a declared-NOCASE column on the
/// RIGHT of a join predicate does NOT drive NOCASE index selection —
/// SQLite uses the left column's (BINARY) collation and rejects a NOCASE
/// sibling index (verified via `EXPLAIN QUERY PLAN`). Commit 6133ccd4a
/// correctly switched codegen to that left-operand rule; these fixtures put
/// the NOCASE column on the right, so swap the ON operands to make the
/// declared-NOCASE column the dominant (left) side while preserving the
/// plain `Column = Column` join shape the grouped/join fast paths match.
fn make_join_predicate_nocase_dominant(stmt: &mut SelectStatement, join_index: usize) {
let SelectCore::Select {
from: Some(from_clause),
..
} = &mut stmt.body.select
else {
unreachable!("fixture should include a FROM clause");
};
let Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp { left, right, .. })) =
from_clause.joins[join_index].constraint.as_mut()
else {
unreachable!("fixture join should carry an ON equality");
};
std::mem::swap(left, right);
}
#[test]
fn test_codegen_multi_join_prefers_collation_matching_unique_lookup() {
let mut stmt = collation_matching_unique_single_column_lookup_multi_join_stmt();
make_join_predicate_nocase_dominant(&mut stmt, 1);
let schema = test_schema_multi_join_prefers_collation_matching_unique_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"a matching-collation unique index should keep the multi-join fast path enabled even if a mismatched unique sibling appears first"
);
let lookup_cmp = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Ne)
.expect("multi-join unique lookup should recheck the landed index key");
assert_eq!(lookup_cmp.p4, P4::Collation("NOCASE".to_owned()));
}
fn test_schema_multi_join_prefers_nocase_unique_lookup() -> Vec<TableSchema> {
vec![
TableSchema {
name: "messages".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("conversation_id", 'D', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "conversations".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("tenant_name", 'B', false),
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
TableSchema {
name: "agents".to_owned(),
root_page: 4,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo {
collation: Some("NOCASE".to_owned()),
..ColumnInfo::basic("tenant_name", 'B', false)
},
ColumnInfo::basic("label", 'B', false),
],
indexes: vec![
IndexSchema {
name: "agents_tenant_idx".to_owned(),
root_page: 5,
columns: vec!["tenant_name".to_owned()],
key_expressions: vec!["tenant_name".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: false,
key_collations: vec![],
conflict_action: None,
},
IndexSchema {
name: "agents_tenant_unique_nocase".to_owned(),
root_page: 6,
columns: vec!["tenant_name".to_owned()],
key_expressions: vec!["tenant_name".to_owned()],
key_sort_directions: vec![],
where_clause: None,
is_unique: true,
key_collations: vec![Some("NOCASE".to_owned())],
conflict_action: None,
},
],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
},
]
}
fn nocase_unique_single_column_lookup_multi_join_stmt() -> SelectStatement {
let on_m_c = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("m", "conversation_id"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("c", "id"), Span::ZERO)),
span: Span::ZERO,
};
let on_c_a = Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("c", "tenant_name"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("a", "tenant_name"),
Span::ZERO,
)),
span: Span::ZERO,
};
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("m", "id"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "label"), Span::ZERO),
alias: None,
},
],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("messages"),
alias: Some("m".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("conversations"),
alias: Some("c".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_m_c)),
},
fsqlite_ast::JoinClause {
join_type: fsqlite_ast::JoinType {
kind: fsqlite_ast::JoinKind::Inner,
natural: false,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("agents"),
alias: Some("a".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(fsqlite_ast::JoinConstraint::On(on_c_a)),
},
],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
#[test]
fn test_codegen_multi_join_unique_lookup_threads_nocase_collation() {
let mut stmt = nocase_unique_single_column_lookup_multi_join_stmt();
make_join_predicate_nocase_dominant(&mut stmt, 1);
let schema = test_schema_multi_join_prefers_nocase_unique_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"the restored multi-join unique lookup fast path should stay enabled for NOCASE indexes"
);
let lookup_cmp = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Ne)
.expect("multi-join unique lookup should recheck the landed index key");
assert_eq!(lookup_cmp.p4, P4::Collation("NOCASE".to_owned()));
}
#[test]
fn test_codegen_multi_join_rejects_unique_lookup_with_collation_mismatch() {
let mut stmt = nocase_unique_single_column_lookup_multi_join_stmt();
let SelectCore::Select {
from: Some(from_clause),
..
} = &mut stmt.body.select
else {
unreachable!("fixture should include a FROM clause");
};
let joins = &mut from_clause.joins;
let Some(fsqlite_ast::JoinConstraint::On(Expr::BinaryOp { left, .. })) =
joins[1].constraint.as_mut()
else {
unreachable!("fixture should include the second ON equality");
};
let original_left =
std::mem::replace(left, Box::new(Expr::Literal(Literal::Null, Span::ZERO)));
**left = Expr::Collate {
expr: original_left,
collation: "BINARY".to_owned(),
span: Span::ZERO,
};
let schema = test_schema_multi_join_prefers_nocase_unique_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let result = codegen_select(&mut b, &stmt, &schema, &ctx);
match result {
Ok(()) => {
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert!(
rewind_count >= 2,
"a COLLATE BINARY join must not take the NOCASE unique-index fast path"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::IdxRowid),
"collation-mismatched joins should fall back to the scan path instead of using direct index lookups"
);
}
Err(CodegenError::Unsupported(_)) => {
// Also acceptable today: once the fast path is rejected, the
// fallback JOIN codegen still refuses explicit COLLATE in the
// ON clause and lets the connection-level interpreter handle it.
}
Err(other) => unreachable!(
"collation-mismatched join should reject the unique-index fast path, got {other:?}"
),
}
}
#[test]
fn test_codegen_grouped_inner_join_uses_index_lookup_plan() {
let stmt = grouped_join_count_sum_index_lookup_stmt();
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"grouped lookup join should only rewind the outer table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"grouped lookup join should seek into the right-side index"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::IdxRowid),
"grouped lookup join should fetch rowids from the right-side index"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterInsert),
"grouped lookup join should still materialize rows for grouped aggregation"
);
}
#[test]
fn test_codegen_complex_select_real_path_evidence_stays_storage_only() {
let cases = [
(
"grouped indexed lookup join",
grouped_join_count_sum_index_lookup_stmt(),
Opcode::SeekGE,
),
(
"grouped rowid lookup join",
grouped_join_count_sum_rowid_lookup_stmt(),
Opcode::SeekRowid,
),
];
for (case_name, stmt, expected_probe) in cases {
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog.requires_attached_memdb(),
"{case_name} should stay on storage cursors for backend-identity replay evidence; got {:?}",
opcode_sequence(&prog)
);
assert!(
prog.ops().iter().any(|op| op.opcode == expected_probe),
"{case_name} should retain its direct probe opcode"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterInsert),
"{case_name} should still use the sorter path for grouped aggregation"
);
assert!(
!prog.ops().iter().any(|op| matches!(
op.opcode,
Opcode::OpenEphemeral
| Opcode::OpenAutoindex
| Opcode::OpenPseudo
| Opcode::OpenDup
| Opcode::ReopenIdx
)),
"{case_name} should not introduce cursor opcodes that force MemDatabase attachment"
);
}
}
#[test]
fn test_codegen_single_join_prefers_collation_matching_lookup_index() {
let mut stmt = collation_matching_single_join_lookup_stmt();
make_join_predicate_nocase_dominant(&mut stmt, 0);
let schema = test_schema_single_join_prefers_collation_matching_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"single-join lookup should keep its fast path when a later sibling index matches the join collation"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_orders_region_nocase")),
"single-join lookup should open the collation-matching sibling index"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::Ne && op.p4 == P4::Collation("NOCASE".to_owned())),
"single-join lookup should recheck landed keys with the join collation"
);
}
#[test]
fn test_codegen_single_join_rejects_composite_lookup_index() {
let stmt = collation_matching_single_join_lookup_stmt();
let schema = test_schema_single_join_rejects_composite_lookup_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 2,
"single-join lookup fast path must reject composite indexes because it only emits a single-key seek record"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_orders_region_amount")),
"falling back to the generic nested-loop join must avoid opening the composite sibling index as a direct lookup cursor"
);
}
#[test]
fn test_codegen_single_join_skips_descending_lookup_sibling() {
let stmt = collation_matching_single_join_lookup_stmt();
let schema = test_schema_single_join_prefers_ascending_lookup_index();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_orders_region_asc")),
"single-join lookup should skip descending siblings and open an ascending direct-lookup index"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_orders_region_desc")),
"single-join lookup must not drive the fast path with a descending index"
);
}
#[test]
fn test_codegen_grouped_join_prefers_collation_matching_lookup_index() {
let mut stmt = collation_matching_grouped_join_lookup_stmt();
make_join_predicate_nocase_dominant(&mut stmt, 0);
let schema = test_schema_single_join_prefers_collation_matching_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"grouped lookup join should keep its fast path when a later sibling index matches the join collation"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(&op.p4, P4::Index(name) if name == "idx_orders_region_nocase")),
"grouped lookup join should open the collation-matching sibling index"
);
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::Ne && op.p4 == P4::Collation("NOCASE".to_owned())),
"grouped lookup join should recheck landed keys with the join collation"
);
}
#[test]
fn test_codegen_grouped_inner_join_uses_rowid_lookup_plan() {
let stmt = grouped_join_count_sum_rowid_lookup_stmt();
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"grouped rowid lookup join should only rewind the outer table"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"grouped rowid lookup join should seek the right table by rowid"
);
assert!(
!prog.ops().iter().any(|op| op.opcode == Opcode::SeekGE),
"grouped rowid lookup join should not use a secondary-index probe"
);
}
#[test]
fn test_extract_count_indexed_exists_target_matches_indexed_outer_column() {
let stmt = agg_count_star_exists_rowid_probe();
let schema = test_schema_with_index_and_subquery_source();
let SelectCore::Select {
from, where_clause, ..
} = &stmt.body.select
else {
unreachable!("expected SELECT core");
};
let FromClause { source, .. } = from.as_ref().expect("outer FROM should exist");
let TableOrSubquery::Table { alias, .. } = source else {
unreachable!("expected plain outer table");
};
let table = find_table(&schema, "t").expect("outer table should exist");
let Expr::Exists { subquery, .. } =
where_clause.as_deref().expect("outer WHERE should exist")
else {
unreachable!("expected EXISTS predicate");
};
let SelectCore::Select {
from: Some(sub_from),
where_clause: Some(sub_where),
..
} = &subquery.body.select
else {
unreachable!("expected simple subquery shape");
};
let TableOrSubquery::Table {
name: sub_name,
alias: sub_alias,
..
} = &sub_from.source
else {
unreachable!("expected plain inner table");
};
let sub_table = find_table(&schema, &sub_name.name).expect("inner table should exist");
let (probe_expr, residual_terms) =
extract_exists_rowid_probe(sub_where, sub_table, sub_alias.as_deref())
.expect("rowid probe should match");
assert_eq!(
column_name(probe_expr, table, alias.as_deref()).as_deref(),
Some("b"),
"outer probe should resolve to the indexed outer column"
);
assert_eq!(
residual_terms.len(),
1,
"fixture should leave exactly one inner residual term"
);
assert!(
table.index_for_column("b").is_some(),
"outer fixture should expose an index on b"
);
let extracted = extract_count_indexed_exists_target(
where_clause.as_deref(),
table,
alias.as_deref(),
&schema,
)
.expect("indexed EXISTS target should match");
assert_eq!(extracted.0.name, "idx_t_b");
match extracted.1 {
CountIndexedInTarget::ProbeSource(probe_source) => {
assert!(matches!(probe_source.value, InProbeValue::Rowid));
}
CountIndexedInTarget::MaterializedProbeSource(_) => {
unreachable!("EXISTS target should lower to a direct probe source");
}
CountIndexedInTarget::List(_) => {
unreachable!("EXISTS target should lower to a probe source");
}
}
}
#[test]
fn test_codegen_scalar_subquery_uses_rowid_probe_when_available() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Subquery(
Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(
ColumnRef::qualified("s", "b"),
Span::ZERO,
),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("s", "rowid"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("t", "a"),
Span::ZERO,
)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}),
Span::ZERO,
),
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SeekRowid),
"scalar subquery on inner rowid should probe directly with SeekRowid"
);
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"only the outer scan should rewind when scalar subquery lowers to a direct rowid probe"
);
let open_read_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::OpenRead)
.count();
assert_eq!(
open_read_count, 2,
"scalar subquery should open the inner table once alongside the outer scan"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Once),
"scalar subquery should hoist its inner cursor open behind Once"
);
}
#[test]
fn test_codegen_scalar_count_star_subquery_uses_count_opcode() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Subquery(Box::new(agg_count_star("s")), Span::ZERO),
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Count),
"scalar COUNT(*) subqueries should use the Count opcode directly"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"scalar COUNT(*) subqueries should bypass generic aggregate opcodes"
);
let rewind_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Rewind)
.count();
assert_eq!(
rewind_count, 1,
"the inner COUNT(*) subquery should not rewind/scan when Count is available"
);
}
#[test]
fn test_codegen_scalar_count_star_subquery_with_where_uses_counter_loop() {
let count_subquery = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("s".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::Subquery(Box::new(count_subquery), Span::ZERO),
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::AddImm),
"scalar COUNT(*) subqueries with WHERE should use a direct counter loop"
);
assert!(
!prog
.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::AggStep | Opcode::AggFinal)),
"scalar COUNT(*) subqueries with WHERE should bypass generic aggregate opcodes"
);
}
#[test]
fn test_codegen_select_correlated_in_subquery_value_expr_does_not_use_once_materialization() {
let where_expr = Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Le,
right: Box::new(Expr::Literal(Literal::Integer(5), Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
})),
not: false,
span: Span::ZERO,
};
let stmt = simple_select(&["a"], "t", Some(Box::new(where_expr)));
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog
.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenAutoindex),
"IN subqueries whose selected probe values depend on outer-row values must not materialize once"
);
}
#[test]
fn test_in_probe_source_reference_detection_considers_secondary_outer_scan() {
let schema = test_schema_with_subquery_source();
let outer_table = &schema[0];
let probe_table = &schema[1];
let secondary_table = TableSchema {
name: "u".to_owned(),
root_page: 4,
columns: vec![ColumnInfo::basic("b", 'd', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
};
let secondaries = [SecondaryScan {
cursor: 1,
table: &secondary_table,
table_alias: Some("u"),
register_base: None,
}];
let scan_ctx = ScanCtx {
cursor: 0,
table: outer_table,
table_alias: Some("t"),
schema: Some(&schema),
register_base: None,
secondaries: &secondaries,
};
let probe_source = InProbeSource {
table: probe_table,
table_alias: Some("s"),
where_clause: Some(&Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "b"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO)),
span: Span::ZERO,
}),
value: InProbeValue::FirstColumn,
};
assert!(
in_probe_source_references_outer_scan(&probe_source, &scan_ctx),
"secondary outer scan references must keep IN probe sources on the correlated path"
);
}
// === Test 21: SELECT sum(col) ===
#[test]
fn test_codegen_select_sum_col() {
let stmt = agg_func_col("sum", "a", "t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have Column (read arg) + AggStep in the loop.
assert!(has_opcodes(
&prog,
&[
Opcode::OpenRead,
Opcode::Rewind,
Opcode::Column,
Opcode::AggStep,
Opcode::Next,
Opcode::AggFinal,
Opcode::ResultRow,
]
));
// AggStep p5 = 1 (one argument).
let step = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::AggStep)
.unwrap();
assert_eq!(step.p5, 1, "sum(col) should have p5=1 (one arg)");
// AggFinal P4 should be FuncName("SUM").
let fin = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::AggFinal)
.unwrap();
assert!(
matches!(&fin.p4, P4::FuncName(f) if f == "SUM"),
"AggFinal P4 should be FuncName(SUM), got {:?}",
fin.p4
);
}
// === Test 22: SELECT count(*), sum(a) ===
#[test]
fn test_codegen_select_multiple_aggregates() {
let stmt = agg_count_star_and_sum("a", "t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Count),
"COUNT(*) in the COUNT+SUM fast path should use Count directly"
);
// COUNT(*) should bypass AggStep/AggFinal; SUM(a) still needs one.
let step_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
assert_eq!(step_count, 1, "COUNT(*) + SUM(a) should only step SUM(a)");
let final_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggFinal)
.count();
assert_eq!(
final_count, 1,
"COUNT(*) + SUM(a) should only finalize SUM(a)"
);
// ResultRow should cover 2 columns.
let rr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::ResultRow)
.unwrap();
assert_eq!(rr.p2, 2, "two aggregate columns");
// The remaining aggregate opcodes should belong to SUM(a).
let steps: Vec<_> = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.collect();
assert!(matches!(&steps[0].p4, P4::FuncName(f) if f == "SUM"));
}
#[test]
fn test_custom_aggregate_keys_preserve_max_arity_resolution() {
let unary = FunctionArgs::List(vec![Expr::Literal(Literal::Integer(1), Span::ZERO)]);
let binary = FunctionArgs::List(vec![
Expr::Literal(Literal::Integer(1), Span::ZERO),
Expr::Literal(Literal::Integer(2), Span::ZERO),
]);
let ternary = FunctionArgs::List(vec![
Expr::Literal(Literal::Integer(1), Span::ZERO),
Expr::Literal(Literal::Integer(2), Span::ZERO),
Expr::Literal(Literal::Integer(3), Span::ZERO),
]);
assert!(is_aggregate_function_call("max", &unary));
assert!(
!is_aggregate_function_call("max", &binary),
"the built-in two-argument max() form is scalar"
);
with_connection_function_context(
vec![("max".to_owned(), FunctionArity::exact(1))],
true,
true,
|| {
assert!(is_aggregate_function_call("max", &unary));
assert!(
!is_aggregate_function_call("max", &binary),
"a custom max/1 must not change max/2"
);
},
);
with_connection_function_context(
vec![("max".to_owned(), FunctionArity::variadic(0, None))],
true,
true,
|| {
assert!(
is_aggregate_function_call("max", &unary),
"the exact built-in max/1 outranks the variadic registration but remains aggregate"
);
assert!(
is_aggregate_function_call("max", &binary),
"a variadic custom max replaces the scalar-shaped max/2 call"
);
},
);
with_connection_function_context(
vec![("max".to_owned(), FunctionArity::variadic(2, Some(2)))],
true,
true,
|| {
assert!(is_aggregate_function_call("max", &binary));
assert!(
!is_aggregate_function_call("max", &ternary),
"a bounded variadic max/2 aggregate must not capture scalar max/3"
);
},
);
with_connection_function_context(
vec![("max".to_owned(), FunctionArity::exact(2))],
true,
true,
|| {
assert!(is_aggregate_function_call("max", &binary));
},
);
with_connection_function_context(
vec![("custom".to_owned(), FunctionArity::exact(1))],
true,
true,
|| {
assert!(is_aggregate_function_call("custom", &unary));
assert!(
!is_aggregate_function_call("custom", &binary),
"a fixed-arity custom aggregate must not capture a scalar overload"
);
},
);
with_connection_function_context(
vec![("custom".to_owned(), FunctionArity::variadic(0, None))],
true,
true,
|| {
assert!(is_aggregate_function_call("custom", &unary));
assert!(is_aggregate_function_call("custom", &binary));
},
);
assert!(
!is_aggregate_function_call("max", &binary),
"the codegen function context must restore its prior arity map"
);
}
#[test]
fn custom_builtin_aggregate_overrides_decline_algebraic_codegen_shortcuts() {
let compile = |stmt: &SelectStatement,
schema: &[TableSchema],
custom_keys: Vec<(String, FunctionArity)>| {
with_connection_function_context(custom_keys, true, true, || {
let mut builder = ProgramBuilder::new();
codegen_select(&mut builder, stmt, schema, &CodegenContext::default())
.expect("custom aggregate fixture should compile");
builder
.finish()
.expect("custom aggregate program should finish")
.ops()
.to_vec()
})
};
let count_ops = compile(
&agg_count_star("t"),
&test_schema(),
vec![("count".to_owned(), FunctionArity::exact(0))],
);
assert!(
!count_ops.iter().any(|op| op.opcode == Opcode::Count),
"custom count/0 must not use the built-in Count opcode"
);
assert!(
count_ops.iter().any(|op| {
op.opcode == Opcode::AggStep
&& matches!(&op.p4, P4::FuncName(name) if name == "COUNT")
}),
"custom count/0 must flow through generic AggStep"
);
let count_sum_ops = compile(
&agg_count_star_and_sum("a", "t"),
&test_schema(),
vec![("sum".to_owned(), FunctionArity::exact(1))],
);
assert!(
!count_sum_ops.iter().any(|op| op.opcode == Opcode::Count),
"custom sum/1 must decline COUNT+SUM fusion because the fused path \
assumes built-in SUM semantics"
);
assert_eq!(
count_sum_ops
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count(),
2,
"declined COUNT+SUM fusion must step both aggregates generically"
);
let mut scalar_stmt = simple_select(&["a"], "t", None);
let SelectCore::Select { columns, .. } = &mut scalar_stmt.body.select else {
unreachable!("simple_select returns a SELECT core");
};
columns.push(ResultColumn::Expr {
expr: Expr::Subquery(Box::new(agg_count_star("s")), Span::ZERO),
alias: None,
});
let scalar_ops = compile(
&scalar_stmt,
&test_schema_with_subquery_source(),
vec![("count".to_owned(), FunctionArity::exact(0))],
);
assert!(
!scalar_ops.iter().any(|op| op.opcode == Opcode::Count),
"custom count/0 inside a scalar subquery must not use Count"
);
assert!(
scalar_ops.iter().any(|op| {
op.opcode == Opcode::AggStep
&& matches!(&op.p4, P4::FuncName(name) if name == "COUNT")
}),
"custom scalar-subquery count/0 must use generic aggregate lowering"
);
}
#[test]
fn custom_count_sum_overrides_decline_grouped_algebraic_plans() {
let table = bd_2dgf5_table();
let bucket_stmt = select_sql("SELECT id / 10, SUM(k) FROM t GROUP BY id / 10");
let SelectCore::Select {
columns, group_by, ..
} = &bucket_stmt.body.select
else {
assert!(test_failure(), "bucket fixture must be a SELECT core");
return;
};
assert!(
simple_group_by_rowid_bucket_sum_plan(columns, &table, None, group_by).is_some(),
"fixture must exercise the grouped rowid-bucket SUM shortcut"
);
with_connection_function_context(
vec![("sum".to_owned(), FunctionArity::exact(1))],
true,
true,
|| {
assert!(
simple_group_by_rowid_bucket_sum_plan(columns, &table, None, group_by)
.is_none(),
"custom sum/1 must decline grouped rowid-bucket SUM algebra"
);
},
);
let join_stmt = grouped_join_count_sum_index_lookup_stmt();
let SelectCore::Select {
from: Some(join_from),
..
} = &join_stmt.body.select
else {
assert!(
test_failure(),
"grouped join fixture must have a FROM clause"
);
return;
};
let join_schema = test_schema_with_join_lookup();
assert!(
grouped_inner_join_count_sum_plan(&join_stmt, join_from, &join_schema)
.expect("baseline grouped join plan")
.is_some(),
"fixture must exercise the grouped inner-join COUNT+SUM shortcut"
);
for custom_key in [
("count".to_owned(), FunctionArity::exact(0)),
("sum".to_owned(), FunctionArity::exact(1)),
] {
with_connection_function_context(vec![custom_key], true, true, || {
assert!(
grouped_inner_join_count_sum_plan(&join_stmt, join_from, &join_schema)
.expect("custom grouped join planning")
.is_none(),
"an exact custom COUNT/0 or SUM/1 must decline grouped join algebra"
);
});
}
}
#[test]
fn custom_minmax_and_count_distinct_overrides_decline_leaf_plans() {
fn aggregate_fixture(sql: &str, table: &TableSchema) -> (SelectStatement, Vec<AggColumn>) {
let stmt = select_sql(sql);
let SelectCore::Select { columns, .. } = &stmt.body.select else {
assert!(test_failure(), "aggregate fixture must be a SELECT core");
return (stmt, Vec::new());
};
let aggregates =
parse_aggregate_columns(columns, table).expect("aggregate fixture should parse");
(stmt, aggregates)
}
fn fixture_where(stmt: &SelectStatement) -> Option<&Expr> {
let SelectCore::Select { where_clause, .. } = &stmt.body.select else {
return None;
};
where_clause.as_deref()
}
fn fixture_columns(stmt: &SelectStatement) -> &[ResultColumn] {
let SelectCore::Select { columns, .. } = &stmt.body.select else {
return &[];
};
columns
}
let table = bd_2dgf5_table();
let (rowid_stmt, rowid_agg) = aggregate_fixture("SELECT MAX(id) FROM t", &table);
let (index_stmt, index_agg) = aggregate_fixture("SELECT MAX(k) FROM t", &table);
let (_pair_stmt, pair_agg) = aggregate_fixture("SELECT MIN(k), MAX(k) FROM t", &table);
let (range_stmt, range_agg) =
aggregate_fixture("SELECT MAX(k) FROM t WHERE k < 10", &table);
let (rowid_range_stmt, rowid_range_agg) =
aggregate_fixture("SELECT MAX(id) FROM t WHERE id < 10", &table);
let (distinct_stmt, distinct_agg) =
aggregate_fixture("SELECT COUNT(DISTINCT k) FROM t", &table);
let prefix_schema = test_schema_with_composite_prefix_index();
let prefix_table = &prefix_schema[0];
let (prefix_stmt, prefix_agg) = aggregate_fixture(
"SELECT MAX(idx) FROM messages WHERE conversation_id = 7",
prefix_table,
);
assert!(
minmax_rowid_seek_plan(&rowid_agg).is_some(),
"fixture must exercise the rowid MIN/MAX leaf seek"
);
assert!(
minmax_index_seek_plan(&index_agg, &table).is_some(),
"fixture must exercise the secondary-index MIN/MAX leaf seek"
);
assert!(
minmax_pair_seek_plan(&pair_agg, &table).is_some(),
"fixture must exercise the paired MIN/MAX leaf seek"
);
assert!(
minmax_range_seek_plan(&range_agg, &table, None, fixture_where(&range_stmt)).is_some(),
"fixture must exercise the bounded secondary-index MIN/MAX leaf seek"
);
assert!(
minmax_rowid_range_seek_plan(
&rowid_range_agg,
&table,
None,
fixture_where(&rowid_range_stmt),
)
.is_some(),
"fixture must exercise the bounded rowid MIN/MAX leaf seek"
);
assert!(
minmax_prefix_seek_plan(&prefix_agg, prefix_table, None, fixture_where(&prefix_stmt),)
.is_some(),
"fixture must exercise the composite-prefix MIN/MAX leaf seek"
);
assert!(
count_distinct_index_walk_plan(&distinct_agg, fixture_columns(&distinct_stmt), &table,)
.is_some(),
"fixture must exercise the COUNT(DISTINCT) index walk"
);
with_connection_function_context(
vec![("max".to_owned(), FunctionArity::exact(1))],
true,
true,
|| {
assert!(
minmax_rowid_seek_plan(&rowid_agg).is_none(),
"custom max/1 must decline the rowid leaf seek"
);
assert!(
minmax_index_seek_plan(&index_agg, &table).is_none(),
"custom max/1 must decline the secondary-index leaf seek"
);
assert!(
minmax_pair_seek_plan(&pair_agg, &table).is_none(),
"custom max/1 must decline the MIN/MAX pair seek"
);
assert!(
minmax_range_seek_plan(&range_agg, &table, None, fixture_where(&range_stmt),)
.is_none(),
"custom max/1 must decline the bounded secondary-index leaf seek"
);
assert!(
minmax_rowid_range_seek_plan(
&rowid_range_agg,
&table,
None,
fixture_where(&rowid_range_stmt),
)
.is_none(),
"custom max/1 must decline the bounded rowid leaf seek"
);
assert!(
minmax_prefix_seek_plan(
&prefix_agg,
prefix_table,
None,
fixture_where(&prefix_stmt),
)
.is_none(),
"custom max/1 must decline the composite-prefix leaf seek"
);
},
);
with_connection_function_context(
vec![("count".to_owned(), FunctionArity::exact(1))],
true,
true,
|| {
assert!(
count_distinct_index_walk_plan(
&distinct_agg,
fixture_columns(&distinct_stmt),
&table,
)
.is_none(),
"custom count/1 must decline COUNT(DISTINCT) index-walk algebra"
);
},
);
// Keep the statements live through all borrowed WHERE checks above and
// make the deliberately no-WHERE fixtures explicit.
assert!(fixture_where(&rowid_stmt).is_none());
assert!(fixture_where(&index_stmt).is_none());
}
// === Test 22b: HAVING-only aggregate is accumulated (bd-3ew8w) ===
#[test]
fn test_codegen_select_having_only_aggregate_is_accumulated() {
let stmt = agg_count_star_having_sum_gt("a", 10, "t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// COUNT(*) in SELECT + SUM(a) in HAVING must both be stepped/finalized.
let step_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
assert_eq!(step_count, 2, "HAVING-only SUM(a) must emit AggStep");
let final_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggFinal)
.count();
assert_eq!(final_count, 2, "HAVING-only SUM(a) must emit AggFinal");
assert!(
prog.ops().iter().any(
|op| matches!(&op.p4, P4::FuncName(f) if op.opcode == Opcode::AggStep && f == "SUM")
),
"expected AggStep for SUM(a) referenced only by HAVING"
);
assert!(
prog.ops().iter().any(
|op| matches!(&op.p4, P4::FuncName(f) if op.opcode == Opcode::AggFinal && f == "SUM")
),
"expected AggFinal for SUM(a) referenced only by HAVING"
);
assert!(
has_opcodes(&prog, &[Opcode::IfNot, Opcode::ResultRow]),
"HAVING clause should emit IfNot guard before ResultRow"
);
let rr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::ResultRow)
.unwrap();
assert_eq!(rr.p2, 1, "query still returns one SELECT column");
}
// === Test 22c: HAVING aggregate deduplicates with SELECT aggregate ===
#[test]
fn test_codegen_select_having_aggregate_reuses_select_aggregate() {
let stmt = agg_count_star_having_count_gt(1, "t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let step_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
assert_eq!(step_count, 1, "COUNT(*) should not be duplicated");
let final_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggFinal)
.count();
assert_eq!(
final_count, 1,
"COUNT(*) finalization should not be duplicated"
);
}
// === Test 23: Non-aggregate SELECT does not emit AggStep ===
#[test]
fn test_codegen_select_no_agg_no_aggstep() {
let stmt = star_select("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let agg_count = prog
.ops()
.iter()
.filter(|op| {
matches!(
op.opcode,
Opcode::AggStep | Opcode::AggFinal | Opcode::AggValue
)
})
.count();
assert_eq!(agg_count, 0, "no aggregate opcodes in non-aggregate SELECT");
}
// === Test 24: Aggregate labels properly resolved ===
#[test]
fn test_codegen_select_aggregate_labels_resolved() {
let stmt = agg_count_star("t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
for op in prog.ops() {
if op.opcode.is_jump() {
assert!(
op.p2 >= 0,
"unresolved jump at {:?}: p2 = {}",
op.opcode,
op.p2
);
assert!(
usize::try_from(op.p2).unwrap() <= prog.len(),
"jump target out of range at {:?}: p2 = {} (prog len = {})",
op.opcode,
op.p2,
prog.len()
);
}
}
}
#[test]
fn test_codegen_select_aggregate_between_wrapper() -> Result<(), String> {
let stmt = select_sql("SELECT SUM(a) BETWEEN 1 AND 10 FROM t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
let agg_steps = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
if agg_steps != 1 {
return Err(format!("expected one SUM AggStep, got {agg_steps}"));
}
let agg_finals = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggFinal)
.count();
if agg_finals != 1 {
return Err(format!("expected one SUM AggFinal, got {agg_finals}"));
}
if !has_opcodes(
&prog,
&[
Opcode::AggStep,
Opcode::AggFinal,
Opcode::Lt,
Opcode::Gt,
Opcode::ResultRow,
],
) {
return Err(format!(
"aggregate BETWEEN wrapper should emit finalized aggregate comparison, got {:?}",
opcode_sequence(&prog)
));
}
Ok(())
}
// === Test 25: Bare column with aggregate (no GROUP BY) ===
#[test]
fn test_codegen_select_mixed_agg_bare_column() {
// SELECT count(*), a FROM t — SQLite allows bare columns without GROUP BY.
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx)
.expect("bare column with aggregate should succeed");
let prog = b.finish().unwrap();
// Should have exactly 1 AggStep (for count(*)) and 1 AggFinal.
let step_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
assert_eq!(step_count, 1, "only count(*) should emit AggStep");
let final_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggFinal)
.count();
assert_eq!(final_count, 1, "only count(*) should emit AggFinal");
// ResultRow should cover 2 columns.
let rr = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::ResultRow)
.unwrap();
assert_eq!(rr.p2, 2, "ResultRow should output 2 columns");
}
#[test]
fn test_codegen_select_star_with_aggregate_expands_bare_columns() -> Result<(), String> {
let stmt = select_sql("SELECT *, COUNT(*) FROM t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
let agg_steps = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::AggStep)
.count();
if agg_steps != 1 {
return Err(format!("expected one COUNT AggStep, got {agg_steps}"));
}
let column_reads = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Column)
.count();
if column_reads < 2 {
return Err(format!(
"expected star expansion to read both table columns, got {column_reads}"
));
}
let result_row = prog.ops().iter().find(|op| op.opcode == Opcode::ResultRow);
if result_row.is_none_or(|op| op.p2 != 3) {
return Err(format!(
"expected ResultRow to expose two star columns plus COUNT, got {:?}",
result_row.map(|op| op.p2)
));
}
Ok(())
}
// === Test 26: AVG aggregate ===
#[test]
fn test_codegen_select_avg() {
let stmt = agg_func_col("avg", "a", "t");
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// AggStep P4 should be "AVG".
let step = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::AggStep)
.unwrap();
assert!(
matches!(&step.p4, P4::FuncName(f) if f == "AVG"),
"AggStep P4 should be FuncName(AVG), got {:?}",
step.p4
);
// AggFinal should also be "AVG".
let fin = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::AggFinal)
.unwrap();
assert!(
matches!(&fin.p4, P4::FuncName(f) if f == "AVG"),
"AggFinal P4 should be FuncName(AVG), got {:?}",
fin.p4
);
}
// === Test: GROUP BY with HAVING clause ===
#[test]
fn test_codegen_select_group_by_having() {
// SELECT a, count(*) FROM t GROUP BY a HAVING count(*) > 1
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![Expr::Column(ColumnRef::bare("a"), Span::ZERO)],
having: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
}),
op: AstBinaryOp::Gt,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
})),
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should produce GROUP BY with HAVING filter: SorterOpen, AggStep,
// AggFinal, IfNot (HAVING check), ResultRow.
assert!(has_opcodes(
&prog,
&[
Opcode::SorterOpen,
Opcode::AggStep,
Opcode::AggFinal,
Opcode::IfNot, // HAVING filter
Opcode::ResultRow,
Opcode::Halt,
]
));
// There should be IfNot opcodes (HAVING filter) in the program.
let if_not_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::IfNot)
.count();
assert!(
if_not_count >= 1,
"HAVING should generate at least one IfNot, got {if_not_count}"
);
}
// === Test: GROUP BY with FILTER clause emits IfNot ===
#[test]
fn test_codegen_select_group_by_filter_emits_ifnot() {
// SELECT a, count(*) FILTER (WHERE b > 0) FROM t GROUP BY a
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Gt,
right: Box::new(Expr::Literal(Literal::Integer(0), Span::ZERO)),
span: Span::ZERO,
})),
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![Expr::Column(ColumnRef::bare("a"), Span::ZERO)],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// There should be an IfNot opcode BEFORE AggStep (the FILTER check).
let agg_step_positions: Vec<usize> = prog
.ops()
.iter()
.enumerate()
.filter(|(_, op)| op.opcode == Opcode::AggStep)
.map(|(i, _)| i)
.collect();
// There should be at least one AggStep (in the sort iteration loop).
assert!(
!agg_step_positions.is_empty(),
"GROUP BY FILTER should have AggStep"
);
// There should be IfNot before AggStep (FILTER check).
let if_not_before_agg = prog.ops().iter().enumerate().any(|(i, op)| {
op.opcode == Opcode::IfNot
&& agg_step_positions
.iter()
.any(|&as_pos| i < as_pos && as_pos - i <= 5)
});
assert!(
if_not_before_agg,
"GROUP BY FILTER should emit IfNot before AggStep"
);
}
#[test]
fn test_codegen_group_by_rowid_bucket_sum_skips_sorter() {
let group_expr = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("id"), Span::ZERO)),
op: AstBinaryOp::Divide,
right: Box::new(Expr::Literal(Literal::Integer(10), Span::ZERO)),
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: group_expr.clone(),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::bare("value"),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(from_table("bench")),
where_clause: None,
group_by: vec![group_expr],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_small_bench_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"rowid-bucket SUM GROUP BY fast path should bypass the sorter"
);
assert!(has_opcodes(
&prog,
&[
Opcode::OpenRead,
Opcode::Int64,
Opcode::Rowid,
Opcode::Divide,
Opcode::AggStep,
Opcode::AggFinal,
Opcode::ResultRow,
Opcode::Halt,
]
));
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.p4, P4::Int64(value) if value == 10)),
"fast path should encode the bucket divisor literal"
);
}
#[test]
fn test_codegen_parsed_group_by_rowid_bucket_sum_skips_sorter() {
let sql = "SELECT (id / 3), SUM(value) FROM bench GROUP BY (id / 3)";
let Some((statement, tail)) = parse_first_statement_with_tail(sql).unwrap() else {
unreachable!("expected parsed statement");
};
assert_eq!(
tail,
sql.len(),
"parser should consume the whole SQL string"
);
let stmt = match statement {
Statement::Select(stmt) => stmt,
other => unreachable!("expected SELECT statement, got {other:?}"),
};
let schema = test_small_bench_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!prog.ops().iter().any(|op| {
matches!(
op.opcode,
Opcode::SorterOpen
| Opcode::SorterInsert
| Opcode::SorterSort
| Opcode::SorterData
| Opcode::SorterNext
)
}),
"parsed rowid-bucket SUM GROUP BY should also bypass the sorter"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.p4, P4::Int64(value) if value == 3)),
"parsed SQL fast path should encode the parsed divisor literal"
);
}
#[test]
fn test_execute_rowid_bucket_projection_keeps_integer_keys() {
let group_expr = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("id"), Span::ZERO)),
op: AstBinaryOp::Divide,
right: Box::new(Expr::Literal(Literal::Integer(3), Span::ZERO)),
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: group_expr,
alias: None,
}],
from: Some(from_table("bench")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let rows = execute_codegen_select_with_storage_cursor(
&stmt,
&test_small_bench_schema(),
seed_small_bench_db(7),
);
assert_eq!(
rows,
vec![
vec![SqliteValue::Integer(0)],
vec![SqliteValue::Integer(0)],
vec![SqliteValue::Integer(0)],
vec![SqliteValue::Integer(1)],
vec![SqliteValue::Integer(1)],
vec![SqliteValue::Integer(1)],
vec![SqliteValue::Integer(2)],
]
);
}
#[test]
fn test_execute_group_by_rowid_bucket_sum_storage_cursor_rows() {
let group_expr = Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("id"), Span::ZERO)),
op: AstBinaryOp::Divide,
right: Box::new(Expr::Literal(Literal::Integer(3), Span::ZERO)),
span: Span::ZERO,
};
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![
ResultColumn::Expr {
expr: group_expr.clone(),
alias: None,
},
ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "sum".to_owned(),
args: FunctionArgs::List(vec![Expr::Column(
ColumnRef::bare("value"),
Span::ZERO,
)]),
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
},
],
from: Some(from_table("bench")),
where_clause: None,
group_by: vec![group_expr],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let rows = execute_codegen_select_with_storage_cursor(
&stmt,
&test_small_bench_schema(),
seed_small_bench_db(7),
);
assert_eq!(
rows,
vec![
vec![SqliteValue::Integer(0), SqliteValue::Float(12.0)],
vec![SqliteValue::Integer(1), SqliteValue::Float(39.0)],
vec![SqliteValue::Integer(2), SqliteValue::Float(19.0)],
]
);
}
// === Tests for bd-2vza: UPDATE/DELETE WHERE with qualified alias columns ===
#[test]
fn test_codegen_update_where_qualified_alias() {
// UPDATE t AS u SET b = ?1 WHERE u.a = ?2
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// The qualified alias "u.a" should resolve to Column opcode
// for filter comparison (Ne), not silently skip filtering.
assert!(has_opcodes(
&prog,
&[
Opcode::Rewind,
Opcode::Column, // read u.a for WHERE comparison
Opcode::Variable,
Opcode::Ne, // filter non-matching rows
]
));
}
#[test]
fn test_codegen_select_projection_expression_wrong_qualifier_errors() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("u", "a"),
Span::ZERO,
)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("projection expression should reject wrong qualifier");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "u" && column == "u.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_update_where_wrong_qualifier_errors() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_update(&mut b, &stmt, &schema, &ctx)
.expect_err("base table qualifier should be hidden by UPDATE alias");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_update_set_unknown_column_errors() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: Expr::Column(ColumnRef::bare("missing"), Span::ZERO),
}],
from: None,
where_clause: None,
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_update(&mut b, &stmt, &schema, &ctx)
.expect_err("SET expression should reject unknown source column");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "missing"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_update_returning_qualified_alias() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO),
alias: None,
}],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
}
#[test]
fn test_codegen_update_where_uses_resolved_collation_and_affinity() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("a".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("name"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_nocase_text_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let filter_cmp = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Ne)
.expect("scan-based WHERE should emit Ne");
assert_eq!(filter_cmp.p4, P4::Collation("NOCASE".to_owned()));
assert_eq!(filter_cmp.p5, 0x80 | u16::from(b'B'));
}
#[test]
fn test_codegen_join_on_nocase_column_emits_collation() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("p", "probe"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("coll_probe"),
alias: Some("p".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![JoinClause {
join_type: JoinType {
natural: false,
kind: JoinKind::Inner,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("coll_words"),
alias: Some("w".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("w", "word"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("p", "probe"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = vec![
TableSchema {
name: "coll_probe".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("probe", 'B', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
TableSchema {
name: "coll_words".to_owned(),
root_page: 3,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo {
collation: Some("NOCASE".to_owned()),
..ColumnInfo::basic("word", 'B', false)
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let join_cmp = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Eq && op.p5 == 0x20)
.expect("JOIN ON equality should emit an Eq STOREP2 opcode");
assert_eq!(join_cmp.p4, P4::Collation("NOCASE".to_owned()));
}
#[test]
fn test_codegen_join_where_current_date_literal_emits_string() -> Result<(), String> {
let stmt = select_sql(
"SELECT c.name FROM customers c CROSS JOIN orders o WHERE CURRENT_DATE IS NOT NULL",
);
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
let has_current_date = prog.ops().iter().any(|op| {
matches!(
&op.p4,
P4::Str(text)
if op.opcode == Opcode::String8
&& text.len() == 10
&& text.as_bytes().get(4) == Some(&b'-')
&& text.as_bytes().get(7) == Some(&b'-')
)
});
if !has_current_date {
return Err(format!(
"JOIN WHERE CURRENT_DATE should emit a date String8 literal, got {:?}",
opcode_sequence(&prog)
));
}
Ok(())
}
#[test]
fn test_codegen_join_where_divide_expression_emits_arithmetic() -> Result<(), String> {
let stmt = select_sql(
"SELECT c.name FROM customers c CROSS JOIN orders o WHERE (o.amount / 2) > 10",
);
let schema = test_schema_with_join_lookup();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
if !has_opcodes(&prog, &[Opcode::Divide, Opcode::Gt, Opcode::IfNot]) {
return Err(format!(
"JOIN WHERE arithmetic expression should emit Divide before comparison, got {:?}",
opcode_sequence(&prog)
));
}
Ok(())
}
fn ambiguous_join_on_stmt(column: &str) -> SelectStatement {
SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("a", "x"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("a"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![JoinClause {
join_type: JoinType {
natural: false,
kind: JoinKind::Inner,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("b"),
alias: None,
index_hint: None,
time_travel: None,
},
constraint: Some(JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare(column), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
}
}
#[test]
fn test_codegen_join_on_unqualified_duplicate_column_is_ambiguous() {
let schema = vec![
TableSchema {
name: "a".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("x", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
TableSchema {
name: "b".to_owned(),
root_page: 3,
columns: vec![ColumnInfo::basic("x", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &ambiguous_join_on_stmt("x"), &schema, &ctx)
.expect_err("unqualified duplicate JOIN column should be ambiguous");
assert!(
matches!(err, CodegenError::AmbiguousColumn(ref name) if name == "x"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_join_on_unqualified_rowid_is_ambiguous() {
let schema = vec![
TableSchema {
name: "a".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("x", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
TableSchema {
name: "b".to_owned(),
root_page: 3,
columns: vec![ColumnInfo::basic("y", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &ambiguous_join_on_stmt("rowid"), &schema, &ctx)
.expect_err("unqualified JOIN rowid should be ambiguous");
assert!(
matches!(err, CodegenError::AmbiguousColumn(ref name) if name == "rowid"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_table_alias_hides_base_table_qualifier() {
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO),
alias: None,
}],
from: Some(from_table_as("t", "u")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("base table qualifier must not resolve after aliasing");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_join_alias_hides_base_table_qualifier() {
let schema = vec![
TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo::basic("a", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
TableSchema {
name: "s".to_owned(),
root_page: 3,
columns: vec![ColumnInfo::basic("a", 'D', false)],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: vec![],
foreign_keys: vec![],
check_constraints: vec![],
},
];
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO),
alias: None,
}],
from: Some(FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
joins: vec![JoinClause {
join_type: JoinType {
natural: false,
kind: JoinKind::Inner,
},
table: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: Some("v".to_owned()),
index_hint: None,
time_travel: None,
},
constraint: Some(JoinConstraint::On(Expr::BinaryOp {
left: Box::new(Expr::Column(
ColumnRef::qualified("t", "a"),
Span::ZERO,
)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(
ColumnRef::qualified("v", "a"),
Span::ZERO,
)),
span: Span::ZERO,
})),
}],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_select(&mut b, &stmt, &schema, &ctx)
.expect_err("JOIN qualifier must use the table alias once present");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_delete_where_qualified_alias() {
// DELETE FROM t AS u WHERE u.a = ?1
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// The qualified alias "u.a" should resolve correctly (two-pass delete).
assert!(has_opcodes(
&prog,
&[
Opcode::Rewind,
Opcode::Column, // read u.a for WHERE comparison
Opcode::Variable,
Opcode::Ne, // filter non-matching rows
Opcode::Rowid, // collect matching rowid
Opcode::RowSetAdd,
Opcode::RowSetRead, // pass 2: delete collected rows
Opcode::SeekRowid,
Opcode::Delete,
]
));
}
#[test]
fn test_codegen_delete_where_wrong_qualifier_errors() {
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_delete(&mut b, &stmt, &schema, &ctx)
.expect_err("base table qualifier should be hidden by DELETE alias");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.a"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_update_where_qualified_rowid_alias() {
// UPDATE t AS u SET b = ?1 WHERE u.rowid = ?2
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("u", "rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// The qualified alias "u.rowid" should resolve to the rowid fast path.
assert!(has_opcodes(&prog, &[Opcode::Variable, Opcode::SeekRowid]));
}
#[test]
fn test_codegen_update_from_generates_nested_loop() {
// UPDATE t SET b = s.b FROM s WHERE t.a = s.b
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO),
}],
from: Some(from_table("s")),
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let opcodes = opcode_sequence(&prog);
// Expect two Rewind opcodes (outer FROM, inner target).
let rewind_count = opcodes.iter().filter(|&&o| o == Opcode::Rewind).count();
assert_eq!(rewind_count, 2, "expected nested loop with 2 Rewind ops");
// Expect two Next opcodes (inner and outer).
let next_count = opcodes.iter().filter(|&&o| o == Opcode::Next).count();
assert_eq!(next_count, 2, "expected nested loop with 2 Next ops");
// Expect OpenWrite for target and OpenRead for FROM.
assert!(
opcodes.contains(&Opcode::OpenWrite),
"expected OpenWrite for target table"
);
assert!(
opcodes.contains(&Opcode::OpenRead),
"expected OpenRead for FROM table"
);
// Expect Delete + Insert for the update-as-delete+insert pattern.
assert!(
opcodes.contains(&Opcode::Delete),
"expected Delete for old row"
);
assert!(
opcodes.contains(&Opcode::Insert),
"expected Insert for updated row"
);
}
#[test]
fn test_codegen_update_from_unknown_set_column_errors() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: Expr::Column(ColumnRef::bare("missing"), Span::ZERO),
}],
from: Some(from_table("s")),
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::qualified("t", "a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Column(ColumnRef::qualified("s", "b"), Span::ZERO)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_update(&mut b, &stmt, &schema, &ctx)
.expect_err("UPDATE FROM SET expression should reject unknown columns");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "missing"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_update_rejects_unmaterialized_order_by_limit() {
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: Some(SortDirection::Desc),
nulls: None,
}],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(1), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(1), Span::ZERO)),
}),
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_update(&mut b, &stmt, &schema, &ctx).unwrap_err();
assert!(
matches!(&err, CodegenError::Unsupported(msg) if msg.contains("materialized")),
"expected explicit unsupported error, got {err:?}"
);
}
#[test]
fn test_codegen_update_where_in_subquery_supported_without_rewrite() {
// UPDATE t SET b = ?1 WHERE a IN (SELECT b FROM s)
let subquery = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(subquery)),
not: false,
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenRead && op.p2 == 3),
"expected subquery probe OpenRead on root page 3"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Eq | Opcode::Found)),
"expected IN membership probe"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Insert),
"expected update writeback Insert"
);
}
#[test]
fn test_codegen_update_set_subquery_anonymous_placeholder_offsets_where_and_returning() {
// UPDATE t
// SET b = a IN (SELECT b FROM s WHERE b = ?)
// WHERE a = ?
// RETURNING ?
//
// SQL placeholder order: SET-subquery first, WHERE second.
// Bytecode emission order is WHERE first, then SET; codegen must offset
// WHERE placeholder numbering so WHERE uses parameter 2.
let set_subquery = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(from_table("s")),
where_clause: Some(Box::new(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("b"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Placeholder(PlaceholderType::Anonymous, Span::ZERO)),
span: Span::ZERO,
})),
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("b".to_owned()),
value: Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(set_subquery)),
not: false,
span: Span::ZERO,
},
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(Expr::Placeholder(PlaceholderType::Anonymous, Span::ZERO)),
span: Span::ZERO,
}),
returning: vec![ResultColumn::Expr {
expr: Expr::Placeholder(PlaceholderType::Anonymous, Span::ZERO),
alias: None,
}],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let variable_params: Vec<i32> = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(
variable_params,
vec![2, 1, 3],
"placeholder numbering must follow SQL lexical order across WHERE (emitted first), SET, and RETURNING"
);
}
#[test]
fn test_codegen_delete_where_in_table_supported_without_rewrite() {
// DELETE FROM t WHERE a IN s
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Table(QualifiedName::bare("s")),
not: false,
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::OpenRead && op.p2 == 3),
"expected IN-table probe OpenRead on root page 3"
);
assert!(
prog.ops()
.iter()
.any(|op| matches!(op.opcode, Opcode::Eq | Opcode::Found)),
"expected IN membership probe"
);
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Delete),
"expected delete operation"
);
}
#[test]
fn test_codegen_delete_where_not_in_subquery_with_order_by_limit() {
// DELETE FROM t WHERE
// a NOT IN (SELECT b FROM s ORDER BY b LIMIT ?1 OFFSET ?2)
// Tests the complex IN subquery path with dynamic LIMIT/OFFSET.
let subquery = SelectStatement {
with: None,
body: fsqlite_ast::SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
alias: None,
}],
from: Some(fsqlite_ast::FromClause {
source: TableOrSubquery::Table {
name: QualifiedName::bare("s"),
alias: None,
index_hint: None,
time_travel: None,
},
joins: vec![],
}),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("b"), Span::ZERO),
direction: None,
nulls: None,
}],
limit: Some(LimitClause {
limit: Expr::Placeholder(fsqlite_ast::PlaceholderType::Numbered(1), Span::ZERO),
offset: Some(Expr::Placeholder(
fsqlite_ast::PlaceholderType::Numbered(2),
Span::ZERO,
)),
}),
};
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::In {
expr: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
set: InSet::Subquery(Box::new(subquery)),
not: true,
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_subquery_source();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Should have SorterOpen for materializing the subquery with ORDER BY.
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SorterOpen),
"expected SorterOpen for ORDER BY subquery"
);
// Should have SorterSort to sort the materialized results.
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::SorterSort),
"expected SorterSort opcode"
);
// Should have SorterInsert to populate the sorter.
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::SorterInsert),
"expected SorterInsert opcode"
);
// Should have DecrJumpZero for LIMIT handling.
assert!(
prog.ops()
.iter()
.any(|op| op.opcode == Opcode::DecrJumpZero),
"expected DecrJumpZero for LIMIT"
);
// Should have Delete opcode for the deletion.
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Delete),
"expected Delete opcode"
);
let ops = prog.ops();
let variable_slots: Vec<i32> = ops
.iter()
.filter(|op| op.opcode == Opcode::Variable)
.map(|op| op.p1)
.collect();
assert_eq!(
variable_slots,
vec![1, 2],
"LIMIT/OFFSET must retain their explicit bind slots"
);
let limit_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 1)
.expect("LIMIT ?1 should be emitted");
let limit_coercion = ops
.iter()
.position(|op| op.opcode == Opcode::MustBeInt)
.expect("dynamic LIMIT should be coerced");
let zero_guard = ops
.iter()
.enumerate()
.skip(limit_coercion + 1)
.find_map(|(index, op)| (op.opcode == Opcode::IfNot).then_some(index))
.expect("LIMIT zero should short-circuit the RHS build");
let offset_variable = ops
.iter()
.position(|op| op.opcode == Opcode::Variable && op.p1 == 2)
.expect("OFFSET ?2 should be emitted");
assert!(
limit_variable < limit_coercion
&& limit_coercion < zero_guard
&& zero_guard < offset_variable,
"LIMIT must be validated and zero-guarded before OFFSET evaluation"
);
assert_eq!(
ops.get(offset_variable + 1).map(|op| op.opcode),
Some(Opcode::MustBeInt),
"dynamic OFFSET should be losslessly coerced"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::IfPos),
"OFFSET should emit a runtime skip counter"
);
assert!(
ops.iter().any(|op| op.opcode == Opcode::Once)
&& ops.iter().any(|op| op.opcode == Opcode::OpenAutoindex)
&& ops.iter().any(|op| op.opcode == Opcode::IdxInsert)
&& ops.iter().any(|op| op.opcode == Opcode::Found),
"complex RHS should build and probe one persistent membership set"
);
}
#[test]
fn test_codegen_delete_rejects_unmaterialized_order_by_limit() {
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![OrderingTerm {
expr: Expr::Column(ColumnRef::bare("a"), Span::ZERO),
direction: None,
nulls: None,
}],
limit: Some(LimitClause {
limit: Expr::Literal(Literal::Integer(2), Span::ZERO),
offset: Some(Expr::Literal(Literal::Integer(1), Span::ZERO)),
}),
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap_err();
assert!(
matches!(&err, CodegenError::Unsupported(msg) if msg.contains("materialized")),
"expected explicit unsupported error, got {err:?}"
);
}
#[test]
fn test_codegen_delete_where_bare_rowid_eq() {
// DELETE FROM t WHERE rowid = ?1
// Ensures unqualified rowid in Eq fast-path emits Rowid opcode.
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// Bare rowid in DELETE WHERE Eq should use the direct rowid probe path.
assert!(has_opcodes(
&prog,
&[
Opcode::Variable,
Opcode::SeekRowid, // direct probe
Opcode::RowSetAdd,
Opcode::RowSetRead, // pass 2: delete collected rows
Opcode::SeekRowid,
Opcode::Delete,
]
));
}
#[test]
fn test_codegen_delete_where_shadowed_rowid_eq_uses_visible_column_filter() {
let stmt = DeleteStatement {
with: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("rowid"), Span::ZERO)),
op: AstBinaryOp::Eq,
right: Box::new(placeholder(1)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = schema_with_visible_rowid_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_delete(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(has_opcodes(
&prog,
&[
Opcode::Rewind,
Opcode::Column, // visible rowid column comparison
Opcode::Variable,
Opcode::Ne,
Opcode::Rowid, // collect hidden rowid for deletion
Opcode::RowSetAdd,
Opcode::RowSetRead,
Opcode::SeekRowid,
Opcode::Delete,
]
));
}
// === Test: UPDATE SET with column self-reference (bd-2eau) ===
#[test]
fn test_codegen_update_set_column_self_ref() {
// UPDATE t SET a = a + 1
// The SET expression `a + 1` should generate a Column opcode to read
// the current value of `a`, NOT a Null opcode.
let stmt = UpdateStatement {
with: None,
or_conflict: None,
table: QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![Assignment {
target: AssignmentTarget::Column("a".to_owned()),
value: Expr::BinaryOp {
left: Box::new(Expr::Column(ColumnRef::bare("a"), Span::ZERO)),
op: AstBinaryOp::Add,
right: Box::new(Expr::Literal(Literal::Integer(1), Span::ZERO)),
span: Span::ZERO,
},
}],
from: None,
where_clause: None,
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// The SET expression should emit Column (reading `a`) + Integer(1) + Add,
// NOT Null. Count Column opcodes — there should be at least 3:
// 2 from reading all columns, plus 1+ from evaluating `a` in the expression.
let column_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Column)
.count();
assert!(
column_count >= 3,
"expected >= 3 Column ops (2 for reading all cols + 1 for SET expr), got {column_count}"
);
// There should be an Add opcode for `a + 1`.
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Add),
"expected Add opcode for `a + 1` expression"
);
// There should be NO Null opcodes for column references.
// (Null would only appear if the ScanCtx was missing.)
let null_count = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Null)
.count();
assert_eq!(
null_count, 0,
"expected 0 Null opcodes (column refs should resolve), got {null_count}"
);
}
// =================================================================
// IPK codegen tests (bd-3l6e / PARITY-B5)
// =================================================================
/// INSERT VALUES with IPK column should emit IsNull+Copy routing, NOT
/// unconditional NewRowid.
#[test]
fn test_codegen_insert_values_ipk_uses_copy_not_new_rowid() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// Must contain IsNull (conditional branch) and Copy (value→rowid).
assert!(
ops.contains(&Opcode::IsNull),
"IPK INSERT should emit IsNull to check for NULL IPK value"
);
assert!(
ops.contains(&Opcode::Copy),
"IPK INSERT should emit Copy to move IPK value to rowid register"
);
// The sequence must be: Variable (values) → IsNull → Copy → Goto
// (or the NULL path: NewRowid → Copy)
assert!(has_opcodes(
&prog,
&[
Opcode::Init,
Opcode::Transaction,
Opcode::OpenWrite,
Opcode::Variable,
Opcode::Variable,
Opcode::IsNull,
Opcode::Copy,
Opcode::MakeRecord,
Opcode::Insert,
Opcode::Close,
Opcode::Halt,
]
));
}
#[test]
fn test_codegen_insert_values_ipk_literals_preformat_record_blob() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![
Expr::Literal(Literal::Integer(7), Span::ZERO),
Expr::Literal(Literal::String("payload".to_owned()), Span::ZERO),
]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
let insert = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::Insert)
.expect("IPK literal INSERT should emit Insert");
assert!(ops.contains(&Opcode::IsNull));
assert!(ops.contains(&Opcode::Copy));
assert!(
!ops.contains(&Opcode::Blob),
"IPK preformatted record should be carried by Insert.p4, not a Blob register"
);
assert!(
!ops.contains(&Opcode::MakeRecord),
"IPK literal INSERT should still preformat the table record payload"
);
assert!(
matches!(&insert.p4, P4::Blob(record) if !record.is_empty()),
"IPK literal Insert should carry the preformatted payload in P4"
);
}
#[test]
fn test_codegen_insert_values_known_schema_uses_precomputed_header() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_real_notnull();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let make_record = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::MakeRecord)
.expect("expected MakeRecord for parameterized INSERT");
assert!(
matches!(&make_record.p4, P4::PrecomputedHeader(header)
if header.template == vec![3, 0, 0]
&& header.slots.len() == 2
&& header.slots[0].kind == PrecomputedSerialTypeKind::NullPlaceholder
&& header.slots[1].kind == PrecomputedSerialTypeKind::RealOrNull),
"expected a precomputed header for IPK + STRICT REAL schema"
);
}
#[test]
fn test_codegen_upsert_update_known_schema_uses_precomputed_header() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("score".to_owned()),
value: Expr::Column(ColumnRef::qualified("excluded", "score"), Span::ZERO),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_real_notnull();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let table_record_headers = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::MakeRecord)
.filter(|op| {
matches!(&op.p4, P4::PrecomputedHeader(header)
if header.template == vec![3, 0, 0]
&& header.slots.len() == 2
&& header.slots[0].kind == PrecomputedSerialTypeKind::NullPlaceholder
&& header.slots[1].kind == PrecomputedSerialTypeKind::RealOrNull)
})
.count();
assert_eq!(
table_record_headers, 2,
"UPSERT DO UPDATE should use precomputed headers for both conflict-update and insert table records"
);
}
fn partial_unique_upsert_schema() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'D', true),
ColumnInfo::basic("email", 'B', false),
ColumnInfo::basic("active", 'D', false),
],
indexes: vec![IndexSchema {
name: "uq_active_email".to_owned(),
root_page: 3,
columns: vec!["email".to_owned()],
key_expressions: Vec::new(),
key_sort_directions: vec![SortDirection::Asc],
where_clause: Some("active = 1".to_owned()),
is_unique: true,
key_collations: vec![None],
conflict_action: None,
}],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
fn partial_unique_upsert_statement(predicate: &str) -> InsertStatement {
InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: Vec::new(),
source: InsertSource::Values(vec![vec![
placeholder(1),
placeholder(2),
placeholder(3),
]]),
upsert: vec![UpsertClause {
target: Some(UpsertTarget {
columns: vec![IndexedColumn {
expr: Expr::Column(ColumnRef::bare("email"), Span::ZERO),
collation: None,
direction: None,
}],
where_clause: Some(expr_sql(predicate)),
}),
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("id".to_owned()),
value: Expr::Column(ColumnRef::qualified("excluded", "id"), Span::ZERO),
}],
where_clause: None,
},
}],
returning: Vec::new(),
}
}
#[test]
fn upsert_partial_unique_target_matches_predicate_and_guards_probe() {
let schema = partial_unique_upsert_schema();
let statement = partial_unique_upsert_statement("active = 1");
let target = statement.upsert[0]
.target
.as_ref()
.expect("test UPSERT should have a conflict target");
assert_eq!(
find_upsert_target_index(&schema[0], Some(target))
.map(|(offset, index)| { (offset, index.name.as_str()) }),
Some((0, "uq_active_email"))
);
let mut builder = ProgramBuilder::new();
codegen_insert(
&mut builder,
&statement,
&schema,
&CodegenContext {
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
},
)
.expect("matching partial UNIQUE target should compile");
let program = builder.finish().expect("UPSERT program should finish");
let predicate_guard = program
.ops()
.iter()
.position(|op| op.opcode == Opcode::IfNot)
.expect("partial UNIQUE probe should be guarded by its predicate");
let conflict_probe = program
.ops()
.iter()
.position(|op| op.opcode == Opcode::NoConflict && op.p1 == 1)
.expect("matching partial UNIQUE index should supply the conflict probe");
assert!(
predicate_guard < conflict_probe,
"the attempted row must satisfy the partial predicate before probing the index"
);
let old_row_delete = program
.ops()
.iter()
.position(|op| op.opcode == Opcode::Delete && op.p5 == OPFLAG_ISUPDATE)
.expect("UPSERT update must remove the conflict victim before reinsertion");
let update_insert = program
.ops()
.iter()
.position(|op| op.opcode == Opcode::Insert && op.p5 & OPFLAG_ISUPDATE != 0)
.expect("UPSERT update must reinsert the rewritten row");
assert!(
conflict_probe < old_row_delete && old_row_delete < update_insert,
"UPSERT must decide the conflict before deleting and reinserting its victim"
);
}
#[test]
fn upsert_partial_unique_target_rejects_mismatched_predicate() {
let schema = partial_unique_upsert_schema();
let statement = partial_unique_upsert_statement("active = 0");
let mut builder = ProgramBuilder::new();
let error = codegen_insert(
&mut builder,
&statement,
&schema,
&CodegenContext {
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
},
)
.expect_err("mismatched partial-index predicate must not fall back to the rowid probe");
assert!(
matches!(error, CodegenError::Unsupported(ref message)
if message.contains("does not match any PRIMARY KEY or UNIQUE constraint")),
"unexpected unmatched-target error: {error:?}"
);
}
#[test]
fn test_codegen_upsert_update_respects_insert_target_alias() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("score".to_owned()),
value: Expr::Column(ColumnRef::qualified("u", "score"), Span::ZERO),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_real_notnull();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
!opcode_sequence(&prog).contains(&Opcode::Null),
"target alias in UPSERT DO UPDATE should resolve to the existing row, not emit NULL"
);
}
#[test]
fn test_codegen_upsert_update_column_list_assignment() -> Result<(), String> {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::ColumnList(vec!["a".to_owned(), "b".to_owned()]),
value: Expr::RowValue(
vec![
Expr::Column(ColumnRef::qualified("excluded", "a"), Span::ZERO),
Expr::Literal(Literal::String("updated".to_owned()), Span::ZERO),
],
Span::ZERO,
),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
if !has_opcodes(
&prog,
&[
Opcode::Copy,
Opcode::String8,
Opcode::MakeRecord,
Opcode::Insert,
],
) {
return Err(format!(
"UPSERT column-list assignment should copy excluded.a and emit literal b, got {:?}",
opcode_sequence(&prog)
));
}
Ok(())
}
#[test]
fn test_codegen_upsert_update_column_list_rejects_arity_mismatch() -> Result<(), String> {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::ColumnList(vec!["a".to_owned(), "b".to_owned()]),
value: Expr::RowValue(
vec![Expr::Column(
ColumnRef::qualified("excluded", "a"),
Span::ZERO,
)],
Span::ZERO,
),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = test_schema();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
match codegen_insert(&mut b, &stmt, &schema, &ctx) {
Err(CodegenError::Unsupported(msg)) if msg.contains("arity mismatch") => Ok(()),
other => Err(format!("expected column-list arity error, got {other:?}")),
}
}
#[test]
fn test_codegen_upsert_between_does_not_inherit_excluded_column_collation() -> Result<(), String>
{
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["name".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("name".to_owned()),
value: Expr::Between {
expr: Box::new(Expr::Column(
ColumnRef::qualified("excluded", "name"),
Span::ZERO,
)),
low: Box::new(Expr::Literal(
Literal::String("alpha".to_owned()),
Span::ZERO,
)),
high: Box::new(Expr::Literal(
Literal::String("omega".to_owned()),
Span::ZERO,
)),
not: false,
span: Span::ZERO,
},
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = test_schema_with_nocase_text_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).map_err(|err| format!("{err:?}"))?;
let prog = b.finish().map_err(|err| format!("{err:?}"))?;
let lt_collations = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Lt)
.map(|op| op.p4.clone())
.collect::<Vec<_>>();
let gt_collations = prog
.ops()
.iter()
.filter(|op| op.opcode == Opcode::Gt)
.map(|op| op.p4.clone())
.collect::<Vec<_>>();
if !matches!(lt_collations.as_slice(), [P4::None])
|| !matches!(gt_collations.as_slice(), [P4::None])
{
return Err(format!(
"excluded.name incorrectly donated a declared collation: lt={lt_collations:?} gt={gt_collations:?}"
));
}
Ok(())
}
#[test]
fn test_codegen_upsert_update_rejects_wrong_target_qualifier() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: Some("u".to_owned()),
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("score".to_owned()),
value: Expr::Column(ColumnRef::qualified("t", "score"), Span::ZERO),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_real_notnull();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx)
.expect_err("UPSERT target alias should hide the base table qualifier");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "t.score"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_upsert_update_unknown_assignment_target_errors() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![UpsertClause {
target: None,
action: UpsertAction::Update {
assignments: vec![Assignment {
target: AssignmentTarget::Column("missing".to_owned()),
value: Expr::Column(ColumnRef::qualified("excluded", "score"), Span::ZERO),
}],
where_clause: None,
},
}],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_real_notnull();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx)
.expect_err("UPSERT DO UPDATE should reject unknown SET targets");
assert!(
matches!(err, CodegenError::ColumnNotFound { ref table, ref column } if table == "t" && column == "missing"),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_insert_values_dynamic_text_schema_falls_back_to_affinity() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_and_strict_text();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let make_record = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::MakeRecord)
.expect("expected MakeRecord for parameterized INSERT");
assert!(
matches!(&make_record.p4, P4::Affinity(aff) if aff == "XB"),
"STRICT TEXT schema should stay on the generic affinity-driven MakeRecord path"
);
}
/// INSERT VALUES without IPK should still use unconditional NewRowid.
#[test]
fn test_codegen_insert_values_no_ipk_uses_new_rowid() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema(); // no IPK
let ctx = CodegenContext::default(); // rowid_alias_col_idx = None
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::NewRowid),
"non-IPK INSERT should use NewRowid"
);
assert!(
!ops.contains(&Opcode::IsNull),
"non-IPK INSERT should NOT emit IsNull routing"
);
}
#[test]
fn test_codegen_insert_visible_rowid_column_does_not_use_explicit_rowid_path() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["rowid".to_owned(), "b".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_visible_rowid_column();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::NewRowid),
"shadowed rowid column should still auto-generate the hidden rowid"
);
assert!(
!ops.contains(&Opcode::IsNull),
"visible rowid column must not be routed through the explicit hidden-rowid path"
);
}
/// INSERT with explicit column list where IPK is in non-first position.
#[test]
fn test_codegen_insert_values_ipk_column_list_reorder() {
// Table: (a INTEGER PRIMARY KEY, b TEXT)
// INSERT INTO t(b, a) VALUES (?, ?) → IPK is at VALUES position 1
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["b".to_owned(), "a".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0), // IPK is column 0 in schema
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// Should still emit IPK routing (IsNull + Copy) because 'a' is in
// the column list at position 1.
assert!(
ops.contains(&Opcode::IsNull),
"reordered column list with IPK should emit IsNull"
);
}
#[test]
fn test_codegen_insert_allows_duplicate_target_columns() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["b".to_owned(), "b".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
}
#[test]
fn test_codegen_insert_allows_hidden_rowid_and_ipk_targets() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["rowid".to_owned(), "a".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
}
#[test]
fn test_codegen_insert_hidden_rowid_after_ipk_uses_rowid_source() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["a".to_owned(), "rowid".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
assert!(
ops.contains(&Opcode::Copy),
"rowid/IPK mixed target list should still emit rowid routing"
);
}
#[test]
fn test_codegen_insert_values_rejects_explicit_column_arity_mismatch() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["b".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap_err();
// a0e51891e switched explicit-column arity mismatch to stock's shorter
// verbatim form ("<n> values for <m> columns") under SQLITE_ERROR.
assert!(
matches!(err, CodegenError::SqlError(ref message) if message.contains("2 values for 1 columns")),
"unexpected error: {err:?}"
);
}
/// INSERT with explicit column list that OMITS the IPK column.
/// The reorder fills the IPK position with NULL, so IsNull routing is
/// emitted but always takes the auto-generate path.
#[test]
fn test_codegen_insert_values_ipk_column_list_omitted() {
// Table: (a INTEGER PRIMARY KEY, b TEXT)
// INSERT INTO t(b) VALUES (?) → IPK omitted, reorder fills NULL
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["b".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1)]]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// Reorder fills IPK slot with NULL → IPK routing still emitted
// (IsNull will always fire, triggering NewRowid).
assert!(
ops.contains(&Opcode::NewRowid),
"omitted IPK should use NewRowid"
);
// The reordered row has 2 columns (full table width), not 1.
let n_null = ops.iter().filter(|&&op| op == Opcode::Null).count();
assert!(
n_null >= 1,
"reorder should emit Null for the omitted IPK column"
);
}
/// Multi-row VALUES with IPK should emit IPK routing for each row.
#[test]
fn test_codegen_insert_values_ipk_multi_row() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![
vec![placeholder(1), placeholder(2)],
vec![placeholder(3), placeholder(4)],
vec![placeholder(5), placeholder(6)],
]),
upsert: vec![],
returning: vec![],
};
let schema = schema_with_ipk_alias();
let ctx = CodegenContext {
concurrent_mode: false,
rowid_alias_col_idx: Some(0),
..CodegenContext::default()
};
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// Three rows → three IsNull opcodes (one per row).
let is_null_count = ops.iter().filter(|&&op| op == Opcode::IsNull).count();
assert_eq!(
is_null_count, 3,
"3-row INSERT with IPK should emit 3 IsNull opcodes, got {is_null_count}"
);
}
#[test]
fn test_codegen_insert_default_values_uses_expression_default() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::DefaultValues,
upsert: vec![],
returning: vec![],
};
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("id", 'd', false),
ColumnInfo {
name: "total".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: Some("(40 + 2)".to_owned()),
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Add),
"expression defaults should compile as expressions, not string literals"
);
}
#[test]
fn test_codegen_insert_default_values_rejects_unparseable_default() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::DefaultValues,
upsert: vec![],
returning: vec![],
};
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![ColumnInfo {
name: "broken".to_owned(),
affinity: 'C',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: Some("('unterminated".to_owned()),
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
}],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap_err();
assert!(
matches!(err, CodegenError::Unsupported(ref msg) if msg.contains("failed to parse DEFAULT expression")),
"unexpected error: {err:?}"
);
}
#[test]
fn test_codegen_insert_default_values_rejects_non_self_contained_default() {
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::DefaultValues,
upsert: vec![],
returning: vec![],
};
let schema = vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'D', false),
ColumnInfo {
name: "b".to_owned(),
affinity: 'D',
is_ipk: false,
type_name: None,
notnull: false,
unique: false,
default_value: Some("(a + 1)".to_owned()),
strict_type: None,
generated_expr: None,
generated_stored: None,
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}];
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
let err = codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap_err();
assert!(
matches!(err, CodegenError::Unsupported(ref msg) if msg.contains("is not self-contained")),
"unexpected error: {err:?}"
);
}
/// Schema: CREATE TABLE t (a INTEGER, b INTEGER, c GENERATED ALWAYS AS (a + b) STORED)
fn test_schema_with_stored_generated() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'd', false),
ColumnInfo {
name: "c".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: Some("INTEGER".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: Some("a + b".to_owned()),
generated_stored: Some(true),
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
/// Schema: CREATE TABLE t (a INTEGER, b INTEGER, c GENERATED ALWAYS AS (a * 2) VIRTUAL)
fn test_schema_with_virtual_generated() -> Vec<TableSchema> {
vec![TableSchema {
name: "t".to_owned(),
root_page: 2,
columns: vec![
ColumnInfo::basic("a", 'd', false),
ColumnInfo::basic("b", 'd', false),
ColumnInfo {
name: "c".to_owned(),
affinity: 'd',
is_ipk: false,
type_name: Some("INTEGER".to_owned()),
notnull: false,
unique: false,
default_value: None,
strict_type: None,
generated_expr: Some("a * 2".to_owned()),
generated_stored: Some(false),
collation: None,
conflict_action: None,
},
],
indexes: vec![],
strict: false,
without_rowid: false,
primary_key_constraints: Vec::new(),
foreign_keys: Vec::new(),
check_constraints: Vec::new(),
}]
}
#[test]
fn test_schema_evaluation_context_codegen_tags_function_owners() {
fn emitted_function_contexts(mut builder: ProgramBuilder) -> Vec<SchemaEvaluationContext> {
builder.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
builder
.finish()
.expect("schema-expression program should build")
.ops()
.iter()
.filter(|op| matches!(op.opcode, Opcode::Function | Opcode::PureFunc))
.map(|op| {
SchemaEvaluationContext::from_function_p1(op.p1)
.expect("schema expression function must carry its owner context")
})
.collect()
}
let schema = test_schema();
let table = &schema[0];
let scan = ScanCtx {
cursor: 0,
table,
table_alias: None,
schema: None,
register_base: None,
secondaries: &[],
};
let index = IndexSchema {
name: "idx_random".to_owned(),
root_page: 3,
columns: Vec::new(),
key_expressions: vec!["random()".to_owned()],
key_sort_directions: vec![],
where_clause: Some("random() != 0".to_owned()),
is_unique: false,
key_collations: vec![],
conflict_action: None,
};
let mut index_builder = ProgramBuilder::new();
let skip = index_builder.emit_label();
emit_index_predicate_guard(&mut index_builder, &index, &scan, skip);
let index_key = index_builder.alloc_reg();
emit_index_key_term(&mut index_builder, &index, 0, index_key, &scan);
index_builder.resolve_label(skip);
assert_eq!(
emitted_function_contexts(index_builder),
[
SchemaEvaluationContext::Index,
SchemaEvaluationContext::Index,
]
);
let mut stored_table = test_schema_with_stored_generated().remove(0);
stored_table.columns[2].generated_expr = Some("random()".to_owned());
let mut stored_builder = ProgramBuilder::new();
let stored_row = stored_builder.alloc_regs(
i32::try_from(stored_table.columns.len()).expect("test column count fits i32"),
);
emit_stored_generated_columns(&mut stored_builder, &stored_table, stored_row);
assert_eq!(
emitted_function_contexts(stored_builder),
[SchemaEvaluationContext::GeneratedColumn]
);
let mut virtual_table = test_schema_with_virtual_generated().remove(0);
virtual_table.columns[2].generated_expr = Some("random()".to_owned());
let mut virtual_builder = ProgramBuilder::new();
let virtual_value = virtual_builder.alloc_reg();
emit_table_column_read(
&mut virtual_builder,
0,
&virtual_table,
None,
None,
2,
virtual_value,
);
assert_eq!(
emitted_function_contexts(virtual_builder),
[SchemaEvaluationContext::GeneratedColumn]
);
let mut check_table = test_schema().remove(0);
check_table.check_constraints = vec![CheckConstraint {
expr: "random() != 0".to_owned(),
owner_column: None,
name: None,
}];
let mut check_builder = ProgramBuilder::new();
let check_row = check_builder.alloc_regs(
i32::try_from(check_table.columns.len()).expect("test column count fits i32"),
);
emit_check_constraints(&mut check_builder, &check_table, check_row, None);
assert_eq!(
emitted_function_contexts(check_builder),
[SchemaEvaluationContext::CheckConstraint]
);
}
#[test]
fn test_codegen_insert_stored_generated_column_emits_copy_and_add() {
// INSERT INTO t VALUES (?, ?, DEFAULT) — 3 columns, c is STORED generated
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![
placeholder(1),
placeholder(2),
Expr::Literal(Literal::Null, Span::ZERO), // placeholder for generated col
]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema_with_stored_generated();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// The generated column (c = a + b) should emit Copy opcodes to read
// columns a and b from their registers, then Add to compute the result.
assert!(
ops.contains(&Opcode::Copy),
"STORED generated column should emit Copy opcodes for column references"
);
assert!(
ops.contains(&Opcode::Add),
"STORED generated column 'a + b' should emit Add opcode"
);
}
#[test]
fn test_codegen_insert_virtual_generated_column_emits_null() {
// INSERT INTO t VALUES (?, ?, DEFAULT) — c is VIRTUAL generated (not stored)
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::Values(vec![vec![
placeholder(1),
placeholder(2),
Expr::Literal(Literal::Null, Span::ZERO),
]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema_with_virtual_generated();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// VIRTUAL generated column should NOT emit Add (expression not evaluated
// at insert time); it should just emit Null.
assert!(
!ops.contains(&Opcode::Multiply),
"VIRTUAL generated column should not evaluate expression during INSERT"
);
}
#[test]
fn test_codegen_insert_default_values_stored_generated() {
// INSERT INTO t DEFAULT VALUES — with a STORED generated column
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec![],
source: InsertSource::DefaultValues,
upsert: vec![],
returning: vec![],
};
let schema = test_schema_with_stored_generated();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// Even with DEFAULT VALUES, stored generated columns should evaluate.
assert!(
ops.contains(&Opcode::Copy),
"DEFAULT VALUES with STORED generated column should emit Copy for references"
);
assert!(
ops.contains(&Opcode::Add),
"DEFAULT VALUES with STORED generated column 'a + b' should emit Add"
);
}
#[test]
fn test_codegen_insert_stored_generated_with_explicit_columns() {
// INSERT INTO t(a, b) VALUES (?, ?) — c is omitted (STORED generated)
let stmt = InsertStatement {
with: None,
or_conflict: None,
table: QualifiedName::bare("t"),
alias: None,
columns: vec!["a".to_owned(), "b".to_owned()],
source: InsertSource::Values(vec![vec![placeholder(1), placeholder(2)]]),
upsert: vec![],
returning: vec![],
};
let schema = test_schema_with_stored_generated();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_insert(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// With explicit column list omitting the generated column, it should
// still evaluate the STORED expression.
assert!(
ops.contains(&Opcode::Add),
"Explicit column list INSERT with STORED generated column should emit Add"
);
}
#[test]
fn test_codegen_update_stored_generated_column_recomputed() {
// UPDATE t SET a = ? WHERE b = ?
// STORED generated column c = a + b should be recomputed.
let stmt = fsqlite_ast::UpdateStatement {
with: None,
or_conflict: None,
table: fsqlite_ast::QualifiedTableRef {
name: QualifiedName::bare("t"),
alias: None,
index_hint: None,
time_travel: None,
},
assignments: vec![fsqlite_ast::Assignment {
target: fsqlite_ast::AssignmentTarget::Column("a".to_owned()),
value: placeholder(1),
}],
from: None,
where_clause: Some(Expr::BinaryOp {
left: Box::new(Expr::Column(
fsqlite_ast::ColumnRef::bare("b"),
Span::ZERO,
)),
op: fsqlite_ast::BinaryOp::Eq,
right: Box::new(placeholder(2)),
span: Span::ZERO,
}),
returning: vec![],
order_by: vec![],
limit: None,
};
let schema = test_schema_with_stored_generated();
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_update(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let ops = opcode_sequence(&prog);
// UPDATE should recompute STORED generated column.
assert!(
ops.contains(&Opcode::Add),
"UPDATE should recompute STORED generated column 'a + b'"
);
}
/// bd-wwqen.1: Verify cheapest-index COUNT optimization fires —
/// when a table has a non-partial index, COUNT(*) opens the index
/// root page instead of the table root page.
#[test]
fn test_count_star_opens_cheapest_index_not_table() {
let schema = test_schema_with_index();
// table root_page = 2, index root_page = 3
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
// The OpenRead should target the INDEX root page (3), not
// the table root page (2).
let open_read = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenRead)
.expect("COUNT(*) program must have OpenRead");
assert_eq!(
open_read.p2, 3,
"bd-wwqen.1: COUNT(*) should open cheapest index (root=3), \
not table (root=2); got root={}",
open_read.p2
);
// The Count opcode must be present.
assert!(
prog.ops().iter().any(|op| op.opcode == Opcode::Count),
"bd-wwqen.1: COUNT(*) program must contain Opcode::Count"
);
}
/// bd-wwqen.1: COUNT(*) on a table with NO indexes falls back
/// to the table root page.
#[test]
fn test_count_star_uses_table_when_no_index() {
let schema = test_schema(); // no indexes
let stmt = SelectStatement {
with: None,
body: SelectBody {
select: SelectCore::Select {
distinct: Distinctness::All,
columns: vec![ResultColumn::Expr {
expr: Expr::FunctionCall {
name: "count".to_owned(),
args: FunctionArgs::Star,
distinct: false,
order_by: vec![],
filter: None,
over: None,
span: Span::ZERO,
},
alias: None,
}],
from: Some(from_table("t")),
where_clause: None,
group_by: vec![],
having: None,
windows: vec![],
},
compounds: vec![],
},
order_by: vec![],
limit: None,
};
let ctx = CodegenContext::default();
let mut b = ProgramBuilder::new();
codegen_select(&mut b, &stmt, &schema, &ctx).unwrap();
let prog = b.finish().unwrap();
let open_read = prog
.ops()
.iter()
.find(|op| op.opcode == Opcode::OpenRead)
.expect("COUNT(*) program must have OpenRead");
assert_eq!(
open_read.p2, 2,
"bd-wwqen.1: COUNT(*) with no indexes should open table (root=2), got root={}",
open_read.p2
);
}
}