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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 12 additions & 34 deletions core/translate/main_loop/seek.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,16 @@
use super::*;

fn index_seek_affinities(
idx: &Index,
tables: &TableReferences,
seek_def: &SeekDef,
seek_key: &SeekKey,
) -> String {
let table = tables
.joined_tables()
.iter()
.find(|jt| jt.table.get_name() == idx.table_name)
.expect("index source table not found in table references");

idx.columns
.iter()
.zip(seek_def.iter(seek_key))
.map(|(ic, key_component)| {
let col_aff = if let Some(ref expr) = ic.expr {
crate::translate::expr::get_expr_affinity(expr, Some(tables), None)
} else {
table
.table
.get_column_at(ic.pos_in_table)
.expect("index column position out of bounds")
.affinity()
};
match key_component {
SeekKeyComponent::Expr(expr) if col_aff.expr_needs_no_affinity_change(expr) => {
affinity::SQLITE_AFF_NONE
}
_ => col_aff.aff_mask(),
fn index_seek_affinities(seek_def: &SeekDef, seek_key: &SeekKey) -> String {
// Apply the constraint's resolved comparison affinity to the seek key,
// not the indexed column's affinity.
seek_def
.iter(seek_key)
.zip(seek_def.iter_affinity(seek_key))
.map(|(key_component, aff)| match key_component {
SeekKeyComponent::Expr(expr) if aff.expr_needs_no_affinity_change(expr) => {
affinity::SQLITE_AFF_NONE
}
_ => aff.aff_mask(),
})
.collect()
}
Expand Down Expand Up @@ -222,8 +202,7 @@ impl<'a, 'plan> SeekEmitter<'a, 'plan> {
0,
&self.t_ctx.resolver,
)?;
let affinities =
index_seek_affinities(idx, self.tables, self.seek_def, &self.seek_def.start);
let affinities = index_seek_affinities(self.seek_def, &self.seek_def.start);
if affinities.chars().any(|c| c != affinity::SQLITE_AFF_NONE) {
self.program.emit_insn(Insn::Affinity {
start_reg: self.start_reg,
Expand Down Expand Up @@ -342,8 +321,7 @@ impl<'a, 'plan> SeekEmitter<'a, 'plan> {
self.seek_def.prefix.len(),
&self.t_ctx.resolver,
)?;
let affinities =
index_seek_affinities(idx, self.tables, self.seek_def, &self.seek_def.end);
let affinities = index_seek_affinities(self.seek_def, &self.seek_def.end);
if affinities.chars().any(|c| c != affinity::SQLITE_AFF_NONE) {
self.program.emit_insn(Insn::Affinity {
start_reg: self.start_reg,
Expand Down
24 changes: 13 additions & 11 deletions core/translate/optimizer/access_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,10 @@ pub(super) fn choose_best_in_seek_candidate(
if table_collation != index_collation {
continue;
}
let idx_aff = constrained_column.affinity_with_strict(rhs_table.table.is_strict());
if !constraint.satisfies_index_affinity(idx_aff) {
continue;
}
}

let rows_per_seek = if (index_info.unique && index_info.column_count == 1)
Expand Down Expand Up @@ -1402,18 +1406,16 @@ fn find_best_access_method_for_subquery(
.iter()
.enumerate()
.filter(|(_, c)| {
c.usable
&& c.table_col_pos.is_some()
&& matches!(
c.operator.as_ast_operator(),
Some(
ast::Operator::Equals
| ast::Operator::Greater
| ast::Operator::GreaterEquals
| ast::Operator::Less
| ast::Operator::LessEquals
)
matches!(
c.operator.as_ast_operator(),
Some(
ast::Operator::Equals
| ast::Operator::Greater
| ast::Operator::GreaterEquals
| ast::Operator::Less
| ast::Operator::LessEquals
)
) && c.can_drive_index_seek(&subquery.columns, false)
})
.collect();

Expand Down
90 changes: 86 additions & 4 deletions core/translate/optimizer/constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use crate::{
schema::{Column, Index, Schema},
translate::{
collate::get_collseq_from_expr,
expr::{as_binary_components, comparison_affinity, walk_expr_mut, WalkControl},
expr::{
as_binary_components, comparison_affinity, get_expr_affinity, unwrap_parens,
walk_expr_mut, WalkControl,
},
expression_index::normalize_expr_for_index_matching,
plan::{JoinOrderMember, JoinedTable, NonFromClauseSubquery, TableReferences, WhereTerm},
planner::{break_predicate_at_and_boundaries, table_mask_from_expr, TableMask, ROWID_STRS},
Expand Down Expand Up @@ -70,6 +73,18 @@ pub struct Constraint {
/// Whether this constraint references the implicit rowid (tables without an INTEGER PRIMARY KEY alias).
/// When true and `table_col_pos` is None, this constraint targets the rowid pseudo-column.
pub is_rowid: bool,
/// The constraint's resolved comparison affinity, as defined by SQLite's
/// `comparisonAffinity` in `expr.c`. Cached at construction time so the
/// `sqlite3IndexAffinityOk` check can run without re-resolving the
/// WhereTerm at every index-selection callsite.
///
/// `None` for forms whose comparison affinity has no single resolved value:
/// FTS MATCH and virtual-table push-downs (operators outside SQLite's
/// comparison set), and row-value IN (`(a,b) IN (...)`, which SQLite
/// handles per-LHS-column via `sqlite3VectorFieldSubexpr` — Turso does
/// not yet plumb per-column affinity into the index-selection path, so
/// such constraints fall through to scans).
pub comparison_affinity: Option<Affinity>,
}

#[derive(Debug, Clone, Copy, PartialEq)]
Expand Down Expand Up @@ -118,8 +133,7 @@ impl Constraint {
panic!("Expected a valid binary expression");
};
let mut affinity = Affinity::Blob;
if op.as_ast_operator().is_some_and(|op| op.is_comparison()) && self.table_col_pos.is_some()
{
if op.as_ast_operator().is_some_and(|op| op.is_comparison()) {
affinity = comparison_affinity(lhs, rhs, referenced_tables, None);
}

Expand Down Expand Up @@ -160,6 +174,35 @@ impl Constraint {
rhs
}
}

/// Returns true when an index column with affinity `idx_aff` can satisfy
/// this constraint per SQLite's `sqlite3IndexAffinityOk`. Constraints
/// whose form has no SQLite-defined comparison affinity (FTS MATCH,
/// virtual-table push-downs, row-value IN) carry `None` and bypass the
/// check — those paths handle types themselves.
pub fn satisfies_index_affinity(&self, idx_aff: Affinity) -> bool {
match self.comparison_affinity {
Some(comparison_aff) => idx_aff.index_affinity_ok(comparison_aff),
None => true,
}
}

/// Whether this constraint can drive an index seek on its target column.
/// Composes the `usable`/`table_col_pos` gates with the affinity check
/// against the column at `table_col_pos` in `columns` (set `is_strict`
/// only for STRICT tables; subqueries pass `false`).
pub fn can_drive_index_seek(&self, columns: &[Column], is_strict: bool) -> bool {
if !self.usable {
return false;
}
let Some(pos) = self.table_col_pos else {
return false;
};
let col = columns.get(pos).unwrap_or_else(|| {
unreachable!("constraint table_col_pos {pos} out of bounds for {columns:?}")
});
self.satisfies_index_affinity(col.affinity_with_strict(is_strict))
}
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -449,6 +492,13 @@ pub fn constraints_from_where_clause(

// Try to extract as binary expression first
if let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? {
// Resolve the comparison affinity once per term per SQLite's
// `comparisonAffinity` (see `Constraint::comparison_affinity`)
// and propagate it to every constraint derived from this term.
let cmp_aff = operator
.as_ast_operator()
.filter(|op| op.is_comparison())
.map(|_| comparison_affinity(lhs, rhs, Some(table_references), None));
// If either the LHS or RHS of the constraint is a column from the table, add the constraint.
match lhs {
ast::Expr::Column { table, column, .. } => {
Expand All @@ -472,6 +522,7 @@ pub fn constraints_from_where_clause(
),
usable: true,
is_rowid: false,
comparison_affinity: cmp_aff,
});
}
}
Expand Down Expand Up @@ -500,6 +551,7 @@ pub fn constraints_from_where_clause(
),
usable: true,
is_rowid: true,
comparison_affinity: cmp_aff,
});
}
}
Expand Down Expand Up @@ -537,6 +589,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: true,
is_rowid: false,
comparison_affinity: cmp_aff,
});
}
_ => {}
Expand All @@ -563,6 +616,7 @@ pub fn constraints_from_where_clause(
),
usable: true,
is_rowid: false,
comparison_affinity: cmp_aff,
});
}
}
Expand Down Expand Up @@ -591,6 +645,7 @@ pub fn constraints_from_where_clause(
),
usable: true,
is_rowid: true,
comparison_affinity: cmp_aff,
});
}
}
Expand Down Expand Up @@ -628,6 +683,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: true,
is_rowid: false,
comparison_affinity: cmp_aff,
});
}
_ => {}
Expand Down Expand Up @@ -658,6 +714,9 @@ pub fn constraints_from_where_clause(
.unwrap_or(params.rows_per_table_fallback as u64)
as f64;
let selectivity = estimate_in_selectivity(estimated_values, row_count, *not);
// SQLite's `comparisonAffinity` for IN-list (`x IN (lit, ...)`)
// is the LHS column's affinity; the RHS literals are not folded.
let cmp_aff = Some(get_expr_affinity(lhs, Some(table_references), None));

match lhs.as_ref() {
ast::Expr::Column { table, column, .. }
Expand All @@ -677,6 +736,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: false, // IN uses a separate seek path, not the range-seek model
is_rowid,
comparison_affinity: cmp_aff,
});
}
ast::Expr::RowId { table, .. } if *table == table_reference.internal_id => {
Expand All @@ -693,6 +753,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: false,
is_rowid: true,
comparison_affinity: cmp_aff,
});
}
_ => {}
Expand All @@ -704,7 +765,7 @@ pub fn constraints_from_where_clause(
subquery_id,
lhs: Some(lhs_expr),
not_in,
query_type: ast::SubqueryType::In { .. },
query_type: ast::SubqueryType::In { affinity_str, .. },
} = &term.expr
{
// Find the subquery to check if it's correlated
Expand All @@ -723,6 +784,19 @@ pub fn constraints_from_where_clause(
.unwrap_or(params.rows_per_table_fallback as u64)
as f64;
let selectivity = estimate_in_selectivity(estimated_values, row_count, *not_in);
// SQLite's `comparisonAffinity` for IN-subquery combines the
// LHS column affinity with each result column via
// `sqlite3CompareAffinity` — that result is already cached on
// `SubqueryType::In::affinity_str`. For a single-LHS-column
// IN it is the first character; row-value IN has no single
// resolved affinity and is left as `None`.
let is_row_value = matches!(
unwrap_parens(lhs_expr.as_ref()).ok(),
Some(ast::Expr::Parenthesized(exprs)) if exprs.len() != 1
);
let cmp_aff = (!is_row_value)
.then(|| affinity_str.chars().next().map(Affinity::from_char))
.flatten();

match lhs_expr.as_ref() {
ast::Expr::Column { table, column, .. }
Expand All @@ -742,6 +816,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: false, // IN uses a separate seek path (consider_in_list_seek)
is_rowid,
comparison_affinity: cmp_aff,
});
}
ast::Expr::RowId { table, .. } if *table == table_reference.internal_id => {
Expand All @@ -758,6 +833,7 @@ pub fn constraints_from_where_clause(
selectivity,
usable: false,
is_rowid: true,
comparison_affinity: cmp_aff,
});
}
_ => {}
Expand Down Expand Up @@ -862,6 +938,11 @@ pub fn constraints_from_where_clause(
{
continue;
}
let idx_col_aff = constrained_column
.affinity_with_strict(table_reference.table.is_strict());
if !constraint.satisfies_index_affinity(idx_col_aff) {
continue;
}
}
if let Some(index_candidate) = cs.candidates.iter_mut().find_map(|candidate| {
if candidate.index.as_ref().is_some_and(|i| {
Expand Down Expand Up @@ -1731,6 +1812,7 @@ pub(crate) fn analyze_binary_term_for_index(
selectivity,
usable: true,
is_rowid,
comparison_affinity: Some(affinity),
};

Some(AnalyzedTerm {
Expand Down
11 changes: 7 additions & 4 deletions core/translate/optimizer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2235,14 +2235,17 @@ fn optimize_table_access(
});
continue;
};
// Ephemeral indexes mirror rowid/column lookups. If the constraint targets an
// expression (table_col_pos == None) we cannot derive a seek key that matches
// the row layout, so fall back to a scan in that situation.
// Ephemeral indexes mirror rowid/column lookups; expression-index
// constraints (table_col_pos == None) fall back to a scan.
let table_columns = table_references.joined_tables()[table_idx].table.columns();
let is_strict = table_references.joined_tables()[table_idx]
.table
.is_strict();
let usable: Vec<(usize, &Constraint)> = table_constraints
.constraints
.iter()
.enumerate()
.filter(|(_, c)| c.usable && c.table_col_pos.is_some())
.filter(|(_, c)| c.can_drive_index_seek(table_columns, is_strict))
.collect();
// Find this table's position in best_join_order (which excludes build tables)
let join_order_pos = best_join_order
Expand Down
25 changes: 25 additions & 0 deletions core/vdbe/affinity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,31 @@ impl Affinity {
}
}

/// Can an index column with `self` affinity drive a seek for a
/// comparison whose resolved affinity is `comparison_aff`?
///
/// Port of SQLite's `sqlite3IndexAffinityOk` (src/expr.c).
/// The basic idea is: an index can be used if probing it would
/// produce the same result as a scan + WHERE filter.
///
/// - `Blob`: e.g. `x IS NULL`. No coercion either way. Any index OK.
/// - `Text`: e.g. `WHERE txt = 5` (txt TEXT). WHERE coerces `5` to
/// `'5'`. Only a TEXT index's keys are stored as text, so it can
/// match `'5'` directly.
/// - `Numeric`: e.g. `WHERE l.txt = r.flag` (l.txt TEXT, r.flag
/// INTEGER). WHERE NUMERIC-coerces both sides: `'6'` → 6, 6 = 6,
/// match. A TEXT-index seek would probe stored text `'6'` with
/// integer 6, but the b-tree comparator orders every integer
/// below every text (INTEGER < TEXT) and finds nothing — opposite
/// answer from the scan. Only a numeric-affinity index is safe.
pub fn index_affinity_ok(self, comparison_aff: Affinity) -> bool {
match comparison_aff {
Affinity::Blob => true,
Affinity::Text => matches!(self, Affinity::Text),
Affinity::Numeric | Affinity::Integer | Affinity::Real => self.is_numeric(),
}
}

/// Return TRUE if the given expression is a constant which would be
/// unchanged by OP_Affinity with the affinity given in the second
/// argument.
Expand Down
Loading
Loading