use vibesql_ast::{pretty_print::ToSql, Expression, IndexColumn};
use vibesql_catalog::TableSchema;
use vibesql_storage::{
statistics::{AccessMethod, CostEstimator},
Database,
};
use crate::evaluator::expression_hash::ExpressionHasher;
use crate::optimizer::index_planner::{IndexPlanner, SkipScanInfo};
#[derive(Debug, Clone)]
pub(crate) enum IndexScanChoice {
Regular {
index_name: String,
sorted_columns: Option<Vec<(String, vibesql_ast::OrderDirection)>>,
},
SkipScan { index_name: String, skip_scan_info: SkipScanInfo },
}
fn any_order_by_column_nullable(
order_items: &[vibesql_ast::OrderByItem],
table_schema: &TableSchema,
index_columns: &[IndexColumn],
pinned_columns: usize,
) -> bool {
let pinned_index_slice = &index_columns[..pinned_columns.min(index_columns.len())];
for item in order_items {
if !pinned_index_slice.is_empty()
&& order_item_matches_pinned_column(item, pinned_index_slice)
{
continue;
}
if let Expression::ColumnRef(col_id) = &item.expr {
let column = col_id.column_canonical();
if let Some(idx) = table_schema.get_column_index(column) {
if table_schema.columns[idx].nullable {
return true;
}
}
}
}
false
}
#[allow(clippy::type_complexity)]
pub(crate) fn should_use_index_scan(
table_name: &str,
where_clause: Option<&Expression>,
order_by: Option<&[vibesql_ast::OrderByItem]>,
database: &Database,
) -> Option<(String, Option<Vec<(String, vibesql_ast::OrderDirection)>>)> {
let table = database.get_table(table_name)?;
let indexes = database.list_indexes_for_table(table_name);
if indexes.is_empty() {
return None;
}
let mut best_index: Option<(
String,
usize,
bool,
Option<Vec<(String, vibesql_ast::OrderDirection)>>,
)> = None;
for index_name in &indexes {
if let Some(index_metadata) = database.get_index(index_name) {
if !crate::optimizer::predicate_implication::partial_index_usable(
database,
index_name,
where_clause,
) {
continue;
}
let first_indexed_column = index_metadata.columns.first()?;
let can_use_for_where = where_clause
.map(|expr| index_column_can_filter(expr, first_indexed_column))
.unwrap_or(false);
let pinned_columns = count_pinned_index_columns(where_clause, &index_metadata.columns);
let can_use_for_order = if let Some(order_items) = order_by {
let columns_match = can_use_index_for_order_by_with_pinned(
order_items,
&index_metadata.columns,
pinned_columns,
);
if columns_match
&& any_order_by_column_nullable(
order_items,
&table.schema,
&index_metadata.columns,
pinned_columns,
)
{
false
} else {
columns_match
}
} else {
false
};
if !can_use_for_where && !can_use_for_order {
continue;
}
let sorted_columns = if can_use_for_order {
let order_items = order_by.unwrap();
Some(
order_items
.iter()
.map(|item| {
let col_name = match &item.expr {
Expression::ColumnRef(col_id) => {
col_id.column_canonical().to_string()
}
expr => expr.to_sql(),
};
(col_name, item.direction.clone())
})
.collect(),
)
} else {
None
};
let is_better = match &best_index {
None => true,
Some((_, best_pinned, best_can_order, _)) => {
if pinned_columns > *best_pinned {
true
} else if pinned_columns == *best_pinned {
can_use_for_order && !*best_can_order
} else {
false
}
}
};
if is_better {
best_index =
Some((index_name.clone(), pinned_columns, can_use_for_order, sorted_columns));
}
}
}
if let Some((index_name, _, _, sorted_columns)) = best_index {
return Some((index_name, sorted_columns));
}
None
}
pub(crate) fn expression_filters_column(expr: &Expression, column_name: &str) -> bool {
match expr {
Expression::BinaryOp { left, op, right } => {
match op {
vibesql_ast::BinaryOperator::Equal
| vibesql_ast::BinaryOperator::GreaterThan
| vibesql_ast::BinaryOperator::GreaterThanOrEqual
| vibesql_ast::BinaryOperator::LessThan
| vibesql_ast::BinaryOperator::LessThanOrEqual => {
let left_is_col = is_column_reference(left, column_name);
let right_is_col = is_column_reference(right, column_name);
let left_is_literal = is_literal(left);
let right_is_literal = is_literal(right);
if (left_is_col && right_is_literal) || (left_is_literal && right_is_col) {
return true;
}
}
vibesql_ast::BinaryOperator::And | vibesql_ast::BinaryOperator::Or => {
return expression_filters_column(left, column_name)
|| expression_filters_column(right, column_name);
}
_ => {}
}
false
}
Expression::IsDistinctFrom { left, right, negated: true } => {
let left_is_col = is_column_reference(left, column_name);
let right_is_col = is_column_reference(right, column_name);
let left_is_literal = is_literal(left);
let right_is_literal = is_literal(right);
(left_is_col && right_is_literal) || (left_is_literal && right_is_col)
}
Expression::InList { expr, .. } => is_column_reference(expr, column_name),
Expression::In { expr, .. } => is_column_reference(expr, column_name),
Expression::Between { expr, .. } => is_column_reference(expr, column_name),
Expression::Conjunction(exprs) => {
exprs.iter().any(|e| expression_filters_column(e, column_name))
}
Expression::Disjunction(exprs) => {
exprs.iter().any(|e| expression_filters_column(e, column_name))
}
_ => false,
}
}
pub(crate) fn expression_filters_index_expression(
where_expr: &Expression,
index_expr: &Expression,
) -> bool {
let index_hash = ExpressionHasher::hash(index_expr);
expression_contains_matching_predicate(where_expr, index_hash)
}
fn expression_contains_matching_predicate(expr: &Expression, target_hash: u64) -> bool {
match expr {
Expression::BinaryOp { left, op, right } => {
match op {
vibesql_ast::BinaryOperator::Equal
| vibesql_ast::BinaryOperator::GreaterThan
| vibesql_ast::BinaryOperator::GreaterThanOrEqual
| vibesql_ast::BinaryOperator::LessThan
| vibesql_ast::BinaryOperator::LessThanOrEqual => {
let left_hash = ExpressionHasher::hash(left);
let right_hash = ExpressionHasher::hash(right);
let left_is_literal = is_literal(left);
let right_is_literal = is_literal(right);
if (left_hash == target_hash && right_is_literal)
|| (right_hash == target_hash && left_is_literal)
{
return true;
}
}
vibesql_ast::BinaryOperator::And | vibesql_ast::BinaryOperator::Or => {
return expression_contains_matching_predicate(left, target_hash)
|| expression_contains_matching_predicate(right, target_hash);
}
_ => {}
}
false
}
Expression::IsDistinctFrom { left, right, negated: true } => {
let left_hash = ExpressionHasher::hash(left);
let right_hash = ExpressionHasher::hash(right);
let left_is_literal = is_literal(left);
let right_is_literal = is_literal(right);
(left_hash == target_hash && right_is_literal)
|| (right_hash == target_hash && left_is_literal)
}
Expression::InList { expr, .. } => ExpressionHasher::hash(expr) == target_hash,
Expression::In { expr, .. } => ExpressionHasher::hash(expr) == target_hash,
Expression::Between { expr, .. } => ExpressionHasher::hash(expr) == target_hash,
Expression::Conjunction(exprs) => {
exprs.iter().any(|e| expression_contains_matching_predicate(e, target_hash))
}
Expression::Disjunction(exprs) => {
exprs.iter().any(|e| expression_contains_matching_predicate(e, target_hash))
}
_ => false,
}
}
pub(crate) fn index_column_can_filter(where_expr: &Expression, index_col: &IndexColumn) -> bool {
match index_col {
IndexColumn::Column { column_name, .. } => {
expression_filters_column(where_expr, column_name)
}
IndexColumn::Expression { expr, .. } => {
expression_filters_index_expression(where_expr, expr)
}
}
}
pub(super) fn is_column_reference(expr: &Expression, column_name: &str) -> bool {
match expr {
Expression::ColumnRef(col_id) => {
col_id.column_canonical().eq_ignore_ascii_case(column_name)
}
_ => false,
}
}
fn is_literal(expr: &Expression) -> bool {
match expr {
Expression::Literal(vibesql_types::SqlValue::Null) => false,
Expression::Literal(_) => true,
Expression::Placeholder(_)
| Expression::NumberedPlaceholder(_)
| Expression::NamedPlaceholder(_) => true,
_ => false,
}
}
pub(crate) fn expressions_match(expr1: &Expression, expr2: &Expression) -> bool {
match (expr1, expr2) {
(Expression::ColumnRef(col1), Expression::ColumnRef(col2)) => {
col1.column_canonical().eq_ignore_ascii_case(&col2.column_canonical())
}
(
Expression::Function { name: name1, args: args1, .. },
Expression::Function { name: name2, args: args2, .. },
) => {
name1.canonical().eq_ignore_ascii_case(name2.canonical())
&& args1.len() == args2.len()
&& args1.iter().zip(args2.iter()).all(|(a1, a2)| expressions_match(a1, a2))
}
(
Expression::BinaryOp { left: l1, op: op1, right: r1 },
Expression::BinaryOp { left: l2, op: op2, right: r2 },
) => op1 == op2 && expressions_match(l1, l2) && expressions_match(r1, r2),
(Expression::UnaryOp { op: op1, expr: e1 }, Expression::UnaryOp { op: op2, expr: e2 }) => {
op1 == op2 && expressions_match(e1, e2)
}
(
Expression::Cast { expr: e1, data_type: t1 },
Expression::Cast { expr: e2, data_type: t2 },
) => t1 == t2 && expressions_match(e1, e2),
(Expression::Literal(v1), Expression::Literal(v2)) => v1 == v2,
(
Expression::Case { operand: op1, when_clauses: wc1, else_result: er1 },
Expression::Case { operand: op2, when_clauses: wc2, else_result: er2 },
) => {
let op_match = match (op1, op2) {
(Some(o1), Some(o2)) => expressions_match(o1, o2),
(None, None) => true,
_ => false,
};
let else_match = match (er1, er2) {
(Some(e1), Some(e2)) => expressions_match(e1, e2),
(None, None) => true,
_ => false,
};
op_match
&& else_match
&& wc1.len() == wc2.len()
&& wc1.iter().zip(wc2.iter()).all(|(w1, w2)| {
w1.conditions.len() == w2.conditions.len()
&& w1
.conditions
.iter()
.zip(w2.conditions.iter())
.all(|(c1, c2)| expressions_match(c1, c2))
&& expressions_match(&w1.result, &w2.result)
})
}
(
Expression::IsNull { expr: e1, negated: n1 },
Expression::IsNull { expr: e2, negated: n2 },
) => n1 == n2 && expressions_match(e1, e2),
(Expression::Conjunction(exprs1), Expression::Conjunction(exprs2)) => {
exprs1.len() == exprs2.len()
&& exprs1.iter().zip(exprs2.iter()).all(|(e1, e2)| expressions_match(e1, e2))
}
(Expression::Disjunction(exprs1), Expression::Disjunction(exprs2)) => {
exprs1.len() == exprs2.len()
&& exprs1.iter().zip(exprs2.iter()).all(|(e1, e2)| expressions_match(e1, e2))
}
_ => expr1 == expr2,
}
}
fn get_column_stats_ignore_case<'a>(
columns: &'a std::collections::HashMap<String, vibesql_storage::statistics::ColumnStatistics>,
column_name: &str,
) -> Option<&'a vibesql_storage::statistics::ColumnStatistics> {
if let Some(stats) = columns.get(column_name) {
return Some(stats);
}
columns.iter().find(|(key, _)| key.eq_ignore_ascii_case(column_name)).map(|(_, stats)| stats)
}
pub(crate) fn can_use_index_for_order_by(
order_items: &[vibesql_ast::OrderByItem],
index_columns: &[vibesql_ast::IndexColumn],
) -> bool {
can_use_index_for_order_by_with_pinned(order_items, index_columns, 0)
}
fn order_item_matches_pinned_column(
item: &vibesql_ast::OrderByItem,
pinned_index_columns: &[vibesql_ast::IndexColumn],
) -> bool {
let order_col_name = match &item.expr {
Expression::ColumnRef(col_id)
if col_id.schema_canonical().is_none() && col_id.table_canonical().is_none() =>
{
col_id.column_canonical()
}
_ => return false,
};
pinned_index_columns.iter().any(|ic| match ic {
vibesql_ast::IndexColumn::Column { column_name, .. } => {
order_col_name.eq_ignore_ascii_case(column_name)
}
vibesql_ast::IndexColumn::Expression { .. } => false,
})
}
pub(crate) fn needs_temp_btree_for_order_by_eqp(
table_name: &str,
where_clause: Option<&Expression>,
order_by: &[vibesql_ast::OrderByItem],
database: &Database,
) -> bool {
let Some(table) = database.get_table(table_name) else {
return true;
};
if order_by_leads_with_rowid_alias(&table.schema, table_name, order_by)
&& cost_based_index_selection(table_name, where_clause, Some(order_by), database).is_none()
&& !has_skip_scan_plan(table_name, where_clause, database)
{
return false;
}
eqp_ordering_index(table_name, where_clause, order_by, database, false).is_none()
}
fn has_skip_scan_plan(
table_name: &str,
where_clause: Option<&Expression>,
database: &Database,
) -> bool {
let Some(where_expr) = where_clause else {
return false;
};
IndexPlanner::new(database).plan_skip_scan(table_name, where_expr).is_some()
}
fn order_by_leads_with_rowid_alias(
schema: &TableSchema,
table_name: &str,
order_by: &[vibesql_ast::OrderByItem],
) -> bool {
let Some(ipk_idx) = schema.rowid_alias_column else {
return false;
};
let Some(ipk_col) = schema.columns.get(ipk_idx) else {
return false;
};
let Some(first) = order_by.first() else {
return false;
};
let Expression::ColumnRef(col_id) = &first.expr else {
return false;
};
if col_id.schema_canonical().is_some() {
return false;
}
if let Some(qualifier) = col_id.table_canonical() {
if !qualifier.eq_ignore_ascii_case(table_name) {
return false;
}
}
let col_name = col_id.column_canonical();
if col_name.eq_ignore_ascii_case(&ipk_col.name) {
return true;
}
let is_rowid_keyword = col_name.eq_ignore_ascii_case("rowid")
|| col_name.eq_ignore_ascii_case("_rowid_")
|| col_name.eq_ignore_ascii_case("oid");
is_rowid_keyword && !schema.without_rowid && schema.get_column_index(col_name).is_none()
}
pub(crate) fn eqp_ordering_index(
table_name: &str,
where_clause: Option<&Expression>,
order_by: &[vibesql_ast::OrderByItem],
database: &Database,
prefer_ordering_scan: bool,
) -> Option<String> {
if order_by.is_empty() {
return None;
}
let chosen = cost_based_index_selection(table_name, where_clause, Some(order_by), database);
if let Some((chosen_name, _)) = &chosen {
if let Some(index_metadata) = database.get_index(chosen_name) {
let pinned_columns = count_pinned_index_columns(where_clause, &index_metadata.columns);
if can_use_index_for_order_by_with_pinned(
order_by,
&index_metadata.columns,
pinned_columns,
) {
return Some(chosen_name.clone());
}
}
}
let indexes = database.list_indexes_for_table(table_name);
for index_name in &indexes {
let Some(index_metadata) = database.get_index(index_name) else { continue };
if !crate::optimizer::predicate_implication::partial_index_usable(
database,
index_name,
where_clause,
) {
continue;
}
if index_metadata.columns.is_empty() {
continue;
}
let pinned_columns = count_pinned_index_columns(where_clause, &index_metadata.columns);
if pinned_columns == 0
&& where_clause.is_some()
&& !(prefer_ordering_scan && chosen.is_none())
{
continue; }
if can_use_index_for_order_by_with_pinned(
order_by,
&index_metadata.columns,
pinned_columns,
) {
return Some(index_name.clone());
}
}
None
}
pub(crate) fn can_use_index_for_order_by_with_pinned(
order_items: &[vibesql_ast::OrderByItem],
index_columns: &[vibesql_ast::IndexColumn],
pinned_columns: usize,
) -> bool {
let remaining_index_columns = &index_columns[pinned_columns..];
let pinned_index_slice = &index_columns[..pinned_columns];
let pinned_skip = order_items
.iter()
.take_while(|item| order_item_matches_pinned_column(item, pinned_index_slice))
.count();
let remaining_order_items = &order_items[pinned_skip..];
if remaining_order_items.is_empty() {
return !order_items.is_empty();
}
if remaining_order_items.len() > remaining_index_columns.len() {
return false;
}
let mut all_match = true;
let mut all_reversed = true;
for (order_item, index_col) in
remaining_order_items.iter().zip(remaining_index_columns.iter())
{
match index_col {
vibesql_ast::IndexColumn::Column { column_name, .. } => {
let order_col_name = match &order_item.expr {
Expression::ColumnRef(col_id)
if col_id.schema_canonical().is_none()
&& col_id.table_canonical().is_none() =>
{
col_id.column_canonical()
}
_ => return false, };
if !order_col_name.eq_ignore_ascii_case(column_name) {
return false;
}
}
vibesql_ast::IndexColumn::Expression { expr: index_expr, .. } => {
if !expressions_match(&order_item.expr, index_expr) {
return false;
}
}
}
let directions_match = order_item.direction == index_col.direction();
let directions_opposite = matches!(
(&order_item.direction, &index_col.direction()),
(vibesql_ast::OrderDirection::Asc, vibesql_ast::OrderDirection::Desc)
| (vibesql_ast::OrderDirection::Desc, vibesql_ast::OrderDirection::Asc)
);
if !directions_match {
all_match = false;
}
if !directions_opposite {
all_reversed = false;
}
}
all_match || all_reversed
}
pub(crate) fn count_pinned_index_columns(
where_clause: Option<&Expression>,
index_columns: &[vibesql_ast::IndexColumn],
) -> usize {
let where_clause = match where_clause {
Some(expr) => expr,
None => return 0,
};
let mut pinned_columns = std::collections::HashSet::new();
collect_equality_columns(where_clause, &mut pinned_columns);
let mut pinned_expressions: Vec<&Expression> = Vec::new();
collect_equality_expressions(where_clause, &mut pinned_expressions);
let mut count = 0;
for index_col in index_columns {
let is_pinned = match index_col {
vibesql_ast::IndexColumn::Column { column_name, .. } => {
pinned_columns.iter().any(|c| c.eq_ignore_ascii_case(column_name))
}
vibesql_ast::IndexColumn::Expression { expr: index_expr, .. } => {
pinned_expressions.iter().any(|e| expressions_match(e, index_expr))
}
};
if is_pinned {
count += 1;
} else {
break; }
}
count
}
fn collect_equality_columns(expr: &Expression, columns: &mut std::collections::HashSet<String>) {
match expr {
Expression::BinaryOp { left, op, right } => {
match op {
vibesql_ast::BinaryOperator::Equal => {
if let Expression::ColumnRef(col_id) = &**left {
if is_literal(right) {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
if let Expression::ColumnRef(col_id) = &**right {
if is_literal(left) {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
}
vibesql_ast::BinaryOperator::And => {
collect_equality_columns(left, columns);
collect_equality_columns(right, columns);
}
_ => {}
}
}
Expression::IsDistinctFrom { left, right, negated: true } => {
if let Expression::ColumnRef(col_id) = &**left {
if is_literal(right) {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
if let Expression::ColumnRef(col_id) = &**right {
if is_literal(left) {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
}
Expression::InList { expr: target, values, negated: false } => {
if let Expression::ColumnRef(col_id) = &**target {
if !values.is_empty() && values.iter().all(is_literal) {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
}
Expression::In { expr: target, negated: false, .. } => {
if let Expression::ColumnRef(col_id) = &**target {
columns.insert(col_id.column_canonical().to_uppercase());
}
}
Expression::Conjunction(exprs) => {
for e in exprs {
collect_equality_columns(e, columns);
}
}
_ => {}
}
}
fn collect_equality_expressions<'a>(expr: &'a Expression, expressions: &mut Vec<&'a Expression>) {
match expr {
Expression::BinaryOp { left, op, right } => {
match op {
vibesql_ast::BinaryOperator::Equal => {
if is_literal(right) && !matches!(&**left, Expression::ColumnRef(_)) {
expressions.push(left);
}
if is_literal(left) && !matches!(&**right, Expression::ColumnRef(_)) {
expressions.push(right);
}
}
vibesql_ast::BinaryOperator::And => {
collect_equality_expressions(left, expressions);
collect_equality_expressions(right, expressions);
}
_ => {}
}
}
Expression::IsDistinctFrom { left, right, negated: true } => {
if is_literal(right) && !matches!(&**left, Expression::ColumnRef(_)) {
expressions.push(left);
}
if is_literal(left) && !matches!(&**right, Expression::ColumnRef(_)) {
expressions.push(right);
}
}
Expression::InList { expr: target, values, negated: false } => {
if !matches!(&**target, Expression::ColumnRef(_))
&& !values.is_empty()
&& values.iter().all(is_literal)
{
expressions.push(target);
}
}
Expression::In { expr: target, negated: false, .. } => {
if !matches!(&**target, Expression::ColumnRef(_)) {
expressions.push(target);
}
}
Expression::Conjunction(exprs) => {
for e in exprs {
collect_equality_expressions(e, expressions);
}
}
_ => {}
}
}
#[allow(clippy::type_complexity)]
pub(crate) fn cost_based_index_selection(
table_name: &str,
where_clause: Option<&Expression>,
order_by: Option<&[vibesql_ast::OrderByItem]>,
database: &Database,
) -> Option<(String, Option<Vec<(String, vibesql_ast::OrderDirection)>>)> {
let table = database.get_table(table_name)?;
let table_stats = table.get_statistics();
if table_stats.is_none() || table_stats.as_ref().map(|s| s.needs_refresh()).unwrap_or(false) {
return should_use_index_scan(table_name, where_clause, order_by, database);
}
let table_stats = table_stats.unwrap();
let cost_estimator = CostEstimator::default();
let indexes = database.list_indexes_for_table(table_name);
if indexes.is_empty() {
return None; }
#[allow(clippy::type_complexity)]
let mut best_index: Option<(
String,
AccessMethod,
usize,
Option<Vec<(String, vibesql_ast::OrderDirection)>>,
)> = None;
let mut has_applicable_index_without_stats = false;
for index_name in &indexes {
if let Some(index_metadata) = database.get_index(index_name) {
if !crate::optimizer::predicate_implication::partial_index_usable(
database,
index_name,
where_clause,
) {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] skipping {} - partial index predicate not implied by query WHERE",
index_name
);
}
continue;
}
let first_indexed_column = index_metadata.columns.first()?;
let can_use_for_where = where_clause
.map(|expr| index_column_can_filter(expr, first_indexed_column))
.unwrap_or(false);
let pinned_columns = count_pinned_index_columns(where_clause, &index_metadata.columns);
let column_name = first_indexed_column.column_name();
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
let col_debug = column_name.unwrap_or("<expression>");
eprintln!(
"[INDEX_SELECT] table={}, index={}, first_col={}, can_use_for_where={}",
table_name, index_name, col_debug, can_use_for_where
);
}
let can_use_for_order = if let Some(order_items) = order_by {
let columns_match = can_use_index_for_order_by_with_pinned(
order_items,
&index_metadata.columns,
pinned_columns,
);
if columns_match
&& any_order_by_column_nullable(
order_items,
&table.schema,
&index_metadata.columns,
pinned_columns,
)
{
false
} else {
columns_match
}
} else {
false
};
if !can_use_for_where && !can_use_for_order {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] skipping {} - can't use for where or order",
index_name
);
}
continue;
}
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] {} passed where/order check, checking stats...",
index_name
);
}
if first_indexed_column.is_expression() {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] {} is expression index, will use rule-based selection",
index_name
);
}
has_applicable_index_without_stats = true;
continue;
}
let col_stats =
column_name.and_then(|cn| get_column_stats_ignore_case(&table_stats.columns, cn));
if col_stats.is_none() {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
let col_debug = column_name.unwrap_or("<expression>");
eprintln!(
"[INDEX_SELECT] {} no column stats for {}, will fallback",
index_name, col_debug
);
}
has_applicable_index_without_stats = true;
continue; }
let col_stats = col_stats.unwrap();
let column_name = column_name.unwrap();
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!("[INDEX_SELECT] {} has column stats for {}", index_name, column_name);
}
let selectivity = if let Some(where_expr) = where_clause {
estimate_selectivity(where_expr, column_name, col_stats)
} else {
1.0 };
let access_method =
cost_estimator.choose_access_method(table_stats, Some(col_stats), selectivity);
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] {} selectivity={:.4}, access_method={:?}, is_index_scan={}",
index_name,
selectivity,
access_method,
access_method.is_index_scan()
);
}
let sorted_columns = if can_use_for_order {
let order_items = order_by.unwrap();
Some(
order_items
.iter()
.map(|item| {
let col_name = match &item.expr {
Expression::ColumnRef(col_id) => {
col_id.column_canonical().to_string()
}
expr => expr.to_sql(),
};
(col_name, item.direction.clone())
})
.collect(),
)
} else {
None
};
if access_method.is_index_scan() {
let is_better = match &best_index {
None => true,
Some((_, best_method, best_pinned, _)) => {
if pinned_columns > *best_pinned {
true
} else if pinned_columns == *best_pinned {
access_method.cost() < best_method.cost()
} else {
false
}
}
};
if is_better {
best_index =
Some((index_name.clone(), access_method, pinned_columns, sorted_columns));
}
} else if selectivity < 0.40 && can_use_for_where {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!(
"[INDEX_SELECT] {} selectivity={:.4} good, falling back to rule-based",
index_name, selectivity
);
}
return should_use_index_scan(table_name, where_clause, order_by, database);
}
}
}
if let Some((index_name, _, _, sorted_columns)) = best_index {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!("[INDEX_SELECT] selected best_index={} for table={}", index_name, table_name);
}
return Some((index_name, sorted_columns));
}
if has_applicable_index_without_stats {
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!("[INDEX_SELECT] falling back to rule-based for table={}", table_name);
}
return should_use_index_scan(table_name, where_clause, order_by, database);
}
if std::env::var("INDEX_SELECT_DEBUG").is_ok() {
eprintln!("[INDEX_SELECT] no index selected for table={}", table_name);
}
None
}
pub(crate) fn estimate_selectivity(
expr: &Expression,
column_name: &str,
col_stats: &vibesql_storage::statistics::ColumnStatistics,
) -> f64 {
match expr {
Expression::BinaryOp { left, op, right } => {
match op {
vibesql_ast::BinaryOperator::Equal => {
if let (Expression::ColumnRef(col_id), Expression::Literal(value)) =
(&**left, &**right)
{
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
return col_stats.estimate_eq_selectivity(value);
}
}
if let (Expression::Literal(value), Expression::ColumnRef(col_id)) =
(&**left, &**right)
{
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
return col_stats.estimate_eq_selectivity(value);
}
}
let left_is_col = is_column_reference(left, column_name);
let right_is_col = is_column_reference(right, column_name);
let left_is_lit = is_literal(left);
let right_is_lit = is_literal(right);
if (left_is_col && right_is_lit) || (left_is_lit && right_is_col) {
if col_stats.n_distinct > 0 {
return 1.0 / col_stats.n_distinct as f64;
}
}
0.33 }
vibesql_ast::BinaryOperator::GreaterThan
| vibesql_ast::BinaryOperator::GreaterThanOrEqual
| vibesql_ast::BinaryOperator::LessThan
| vibesql_ast::BinaryOperator::LessThanOrEqual => {
if let (Expression::ColumnRef(col_id), Expression::Literal(value)) =
(&**left, &**right)
{
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
let op_str = match op {
vibesql_ast::BinaryOperator::GreaterThan => ">",
vibesql_ast::BinaryOperator::GreaterThanOrEqual => ">=",
vibesql_ast::BinaryOperator::LessThan => "<",
vibesql_ast::BinaryOperator::LessThanOrEqual => "<=",
_ => unreachable!(),
};
return col_stats.estimate_range_selectivity(value, op_str);
}
}
if (is_column_reference(left, column_name) && is_literal(right))
|| (is_literal(left) && is_column_reference(right, column_name))
{
return 0.25;
}
0.33 }
vibesql_ast::BinaryOperator::And => {
let left_sel = estimate_selectivity(left, column_name, col_stats);
let right_sel = estimate_selectivity(right, column_name, col_stats);
left_sel * right_sel
}
vibesql_ast::BinaryOperator::Or => {
let left_sel = estimate_selectivity(left, column_name, col_stats);
let right_sel = estimate_selectivity(right, column_name, col_stats);
left_sel + right_sel - (left_sel * right_sel)
}
_ => 0.33, }
}
Expression::Between { expr, low, high, negated: _, symmetric: _ } => {
if let Expression::ColumnRef(col_id) = &**expr {
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
if let (Expression::Literal(low_val), Expression::Literal(high_val)) =
(&**low, &**high)
{
let low_sel = col_stats.estimate_range_selectivity(low_val, ">=");
let high_sel = col_stats.estimate_range_selectivity(high_val, "<=");
return low_sel * high_sel; }
}
}
0.33 }
Expression::IsDistinctFrom { left, right, negated: true } => {
if let (Expression::ColumnRef(col_id), Expression::Literal(value)) = (&**left, &**right)
{
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
return col_stats.estimate_eq_selectivity(value);
}
}
if let (Expression::Literal(value), Expression::ColumnRef(col_id)) = (&**left, &**right)
{
if col_id.column_canonical().eq_ignore_ascii_case(column_name) {
return col_stats.estimate_eq_selectivity(value);
}
}
let left_is_col = is_column_reference(left, column_name);
let right_is_col = is_column_reference(right, column_name);
let left_is_lit = is_literal(left);
let right_is_lit = is_literal(right);
if (left_is_col && right_is_lit) || (left_is_lit && right_is_col) {
if col_stats.n_distinct > 0 {
return 1.0 / col_stats.n_distinct as f64;
}
}
0.33 }
Expression::Conjunction(exprs) => {
let mut selectivity = 1.0;
for e in exprs {
selectivity *= estimate_selectivity(e, column_name, col_stats);
}
selectivity
}
Expression::Disjunction(exprs) => {
let mut selectivity = 0.0;
let mut product = 1.0;
for e in exprs {
let sel = estimate_selectivity(e, column_name, col_stats);
selectivity += sel;
product *= sel;
}
(selectivity - product).min(1.0)
}
_ => 0.33, }
}
pub(crate) fn select_index_scan_method(
table_name: &str,
where_clause: Option<&Expression>,
order_by: Option<&[vibesql_ast::OrderByItem]>,
database: &Database,
) -> Option<IndexScanChoice> {
if let Some((index_name, sorted_columns)) =
cost_based_index_selection(table_name, where_clause, order_by, database)
{
return Some(IndexScanChoice::Regular { index_name, sorted_columns });
}
if let Some(where_expr) = where_clause {
let planner = IndexPlanner::new(database);
if let Some(plan) = planner.plan_skip_scan(table_name, where_expr) {
if plan.is_skip_scan {
if let Some(skip_info) = plan.skip_scan_info {
if std::env::var("SKIP_SCAN_DEBUG").is_ok() {
eprintln!(
"[SKIP_SCAN] Selected skip-scan for table={}, index={}, filter_col={}, prefix_cardinality={}",
table_name,
plan.index_name,
skip_info.filter_column,
skip_info.prefix_cardinality
);
}
return Some(IndexScanChoice::SkipScan {
index_name: plan.index_name,
skip_scan_info: skip_info,
});
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use vibesql_ast::BinaryOperator;
use vibesql_types::SqlValue;
use super::*;
#[test]
fn test_expression_filters_column_simple() {
let expr = Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"age", false,
))),
right: Box::new(Expression::Literal(SqlValue::Integer(25))),
};
assert!(expression_filters_column(&expr, "age"));
assert!(!expression_filters_column(&expr, "name"));
}
#[test]
fn test_expression_filters_column_and() {
let expr = Expression::BinaryOp {
op: BinaryOperator::And,
left: Box::new(Expression::BinaryOp {
op: BinaryOperator::GreaterThan,
left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"age", false,
))),
right: Box::new(Expression::Literal(SqlValue::Integer(18))),
}),
right: Box::new(Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"city", false,
))),
right: Box::new(Expression::Literal(SqlValue::Varchar(arcstr::ArcStr::from(
"Boston",
)))),
}),
};
assert!(expression_filters_column(&expr, "age"));
assert!(expression_filters_column(&expr, "city"));
assert!(!expression_filters_column(&expr, "name"));
}
#[test]
fn test_is_column_reference() {
let expr = Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("age", false));
assert!(is_column_reference(&expr, "age"));
assert!(!is_column_reference(&expr, "name"));
}
#[test]
fn test_is_column_reference_case_insensitive() {
let expr = Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("I_ID", false));
assert!(is_column_reference(&expr, "i_id")); assert!(is_column_reference(&expr, "I_ID")); assert!(is_column_reference(&expr, "I_id")); assert!(!is_column_reference(&expr, "other_column"));
}
#[test]
fn test_expression_filters_column_case_insensitive() {
let expr = Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"I_ID", false,
))),
right: Box::new(Expression::Literal(SqlValue::Integer(42))),
};
assert!(expression_filters_column(&expr, "i_id"));
assert!(expression_filters_column(&expr, "I_ID"));
assert!(!expression_filters_column(&expr, "other"));
}
fn idx_cols(names: &[&str]) -> Vec<vibesql_ast::IndexColumn> {
names
.iter()
.map(|n| vibesql_ast::IndexColumn::Column {
column_name: n.to_string(),
direction: vibesql_ast::OrderDirection::Asc,
prefix_length: None,
})
.collect()
}
fn in_list_expr(col: &str, values: Vec<SqlValue>) -> Expression {
Expression::InList {
expr: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
col, false,
))),
values: values.into_iter().map(Expression::Literal).collect(),
negated: false,
}
}
#[test]
fn test_count_pinned_columns_in_list() {
let where_expr =
in_list_expr("a", vec![SqlValue::Integer(1), SqlValue::Integer(2), SqlValue::Integer(3)]);
let index = idx_cols(&["a", "b"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 1);
}
#[test]
fn test_count_pinned_columns_not_in_list_excluded() {
let where_expr = Expression::InList {
expr: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"a", false,
))),
values: vec![
Expression::Literal(SqlValue::Integer(1)),
Expression::Literal(SqlValue::Integer(2)),
],
negated: true,
};
let index = idx_cols(&["a", "b"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 0);
}
#[test]
fn test_count_pinned_columns_empty_in_list_excluded() {
let where_expr = Expression::InList {
expr: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"a", false,
))),
values: vec![],
negated: false,
};
let index = idx_cols(&["a", "b"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 0);
}
#[test]
fn test_count_pinned_columns_in_list_with_non_literal_excluded() {
let where_expr = Expression::InList {
expr: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"a", false,
))),
values: vec![
Expression::Literal(SqlValue::Integer(1)),
Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("b", false)),
],
negated: false,
};
let index = idx_cols(&["a", "b"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 0);
}
#[test]
fn test_count_pinned_columns_in_list_with_null_still_pins() {
let where_expr = Expression::InList {
expr: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"a", false,
))),
values: vec![
Expression::Literal(SqlValue::Integer(1)),
Expression::Literal(SqlValue::Null),
Expression::Literal(SqlValue::Integer(3)),
],
negated: false,
};
let index = idx_cols(&["a", "b"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 0);
}
#[test]
fn test_count_pinned_columns_in_list_combined_with_equality() {
let in_a = in_list_expr("a", vec![SqlValue::Integer(1), SqlValue::Integer(2)]);
let eq_b = Expression::BinaryOp {
op: BinaryOperator::Equal,
left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple(
"b", false,
))),
right: Box::new(Expression::Literal(SqlValue::Integer(5))),
};
let where_expr = Expression::BinaryOp {
op: BinaryOperator::And,
left: Box::new(in_a),
right: Box::new(eq_b),
};
let index = idx_cols(&["a", "b", "c"]);
assert_eq!(count_pinned_index_columns(Some(&where_expr), &index), 2);
}
#[test]
fn test_can_use_index_for_order_by_with_pinned_in_list_leading() {
let order_by = vec![
vibesql_ast::OrderByItem {
expr: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("a", false)),
direction: vibesql_ast::OrderDirection::Asc,
nulls_order: None,
},
vibesql_ast::OrderByItem {
expr: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("b", false)),
direction: vibesql_ast::OrderDirection::Asc,
nulls_order: None,
},
];
let index = idx_cols(&["a", "b"]);
assert!(can_use_index_for_order_by_with_pinned(&order_by, &index, 1));
}
#[test]
fn test_can_use_index_for_order_by_all_pinned() {
let order_by = vec![
vibesql_ast::OrderByItem {
expr: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("a", false)),
direction: vibesql_ast::OrderDirection::Asc,
nulls_order: None,
},
vibesql_ast::OrderByItem {
expr: Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("b", false)),
direction: vibesql_ast::OrderDirection::Asc,
nulls_order: None,
},
];
let index = idx_cols(&["a", "b"]);
assert!(can_use_index_for_order_by_with_pinned(&order_by, &index, 2));
}
}