use vibesql_ast::{Expression, OrderByItem};
use vibesql_storage::{statistics::CostEstimator, Database};
#[allow(dead_code)]
pub struct IndexPlanner<'a> {
database: &'a Database,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct IndexPlan {
pub index_name: String,
pub fully_satisfies_where: bool,
pub sorted_columns: Option<Vec<(String, vibesql_ast::OrderDirection)>>,
pub estimated_selectivity: f64,
pub is_skip_scan: bool,
pub skip_scan_info: Option<SkipScanInfo>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct SkipScanInfo {
pub skip_columns: usize,
pub prefix_columns: Vec<String>,
pub filter_column: String,
pub prefix_cardinality: usize,
pub estimated_cost: f64,
}
#[allow(dead_code)]
impl<'a> IndexPlanner<'a> {
pub fn new(database: &'a Database) -> Self {
IndexPlanner { database }
}
pub fn plan_index_usage(
&self,
table_name: &str,
where_clause: Option<&Expression>,
order_by: Option<&[OrderByItem]>,
) -> Option<IndexPlan> {
let (index_name, sorted_columns) =
crate::select::scan::index_scan::cost_based_index_selection(
table_name,
where_clause,
order_by,
self.database,
)?;
let fully_satisfies_where = if let Some(where_expr) = where_clause {
if let Some(index_metadata) = self.database.get_index(&index_name) {
if let Some(first_col) = index_metadata.columns.first() {
first_col.column_name().map(|col_name| {
crate::select::scan::index_scan::predicate::where_clause_fully_satisfied_by_index(
where_expr,
col_name,
)
}).unwrap_or(false)
} else {
false
}
} else {
false
}
} else {
true };
let estimated_selectivity = self.estimate_selectivity(&index_name, where_clause);
Some(IndexPlan {
index_name,
fully_satisfies_where,
sorted_columns,
estimated_selectivity,
is_skip_scan: false,
skip_scan_info: None,
})
}
pub fn plan_skip_scan(&self, table_name: &str, where_clause: &Expression) -> Option<IndexPlan> {
let table = self.database.get_table(table_name)?;
let table_stats = table.get_statistics()?;
if table_stats.needs_refresh() {
return None; }
let indexes = self.database.list_indexes_for_table(table_name);
let cost_estimator = CostEstimator::default();
let table_scan_cost = cost_estimator.estimate_table_scan(table_stats);
let mut best_skip_scan: Option<(String, SkipScanInfo, f64)> = None;
for index_name in &indexes {
if let Some(index_metadata) = self.database.get_index(index_name) {
if !crate::optimizer::predicate_implication::partial_index_usable(
self.database,
index_name,
Some(where_clause),
) {
continue;
}
if index_metadata.columns.len() < 2 {
continue;
}
let first_col = &index_metadata.columns[0];
let filters_first_col =
crate::select::scan::index_scan::selection::expression_filters_column(
where_clause,
first_col.expect_column_name(),
);
if filters_first_col {
continue; }
for (filter_col_idx, filter_col) in
index_metadata.columns.iter().enumerate().skip(1)
{
let filters_this_col =
crate::select::scan::index_scan::selection::expression_filters_column(
where_clause,
filter_col.expect_column_name(),
);
if !filters_this_col {
continue;
}
let filter_col_stats = table_stats.columns.get(filter_col.expect_column_name());
let filter_selectivity = filter_col_stats
.map(|stats| {
crate::select::scan::index_scan::selection::estimate_selectivity(
where_clause,
filter_col.expect_column_name(),
stats,
)
})
.unwrap_or(0.33);
for skip_depth in 1..=filter_col_idx {
let prefix_stats: Vec<_> = index_metadata.columns[..skip_depth]
.iter()
.filter_map(|c| table_stats.columns.get(c.expect_column_name()))
.collect();
if prefix_stats.len() != skip_depth {
continue;
}
let skip_scan_cost = if skip_depth == 1 {
cost_estimator.estimate_skip_scan_cost(
table_stats,
prefix_stats[0],
filter_selectivity,
)
} else {
cost_estimator.estimate_skip_scan_cost_multi_column(
table_stats,
&prefix_stats,
filter_selectivity,
)
};
if std::env::var("SKIP_SCAN_DEBUG").is_ok() {
let prefix_names: Vec<_> = index_metadata.columns[..skip_depth]
.iter()
.map(|c| c.expect_column_name())
.collect();
eprintln!(
"[SKIP_SCAN] index={}, skip_depth={}, prefix_cols={:?}, filter_col={}, filter_selectivity={:.4}, skip_scan_cost={:.2}, table_scan_cost={:.2}",
index_name,
skip_depth,
prefix_names,
filter_col.expect_column_name(),
filter_selectivity,
skip_scan_cost,
table_scan_cost
);
}
if skip_scan_cost >= table_scan_cost {
continue;
}
let is_better = match &best_skip_scan {
None => true,
Some((_, _, best_cost)) => skip_scan_cost < *best_cost,
};
if is_better {
let prefix_columns: Vec<String> = index_metadata.columns[..skip_depth]
.iter()
.map(|c| c.expect_column_name().to_string())
.collect();
let prefix_cardinality = if skip_depth == 1 {
prefix_stats[0].n_distinct
} else {
let combined = cost_estimator.estimate_skip_scan_cost_multi_column(
table_stats,
&prefix_stats,
1.0, );
(combined / cost_estimator.random_page_cost).ceil() as usize
};
let skip_info = SkipScanInfo {
skip_columns: skip_depth,
prefix_columns,
filter_column: filter_col.expect_column_name().to_string(),
prefix_cardinality,
estimated_cost: skip_scan_cost,
};
best_skip_scan = Some((index_name.clone(), skip_info, skip_scan_cost));
}
}
break;
}
}
}
best_skip_scan.map(|(index_name, skip_info, _)| {
let estimated_selectivity =
skip_info.prefix_cardinality as f64 / table_stats.row_count.max(1) as f64 * 0.33;
IndexPlan {
index_name,
fully_satisfies_where: false, sorted_columns: None, estimated_selectivity,
is_skip_scan: true,
skip_scan_info: Some(skip_info),
}
})
}
fn estimate_selectivity(&self, index_name: &str, where_clause: Option<&Expression>) -> f64 {
let where_expr = match where_clause {
Some(expr) => expr,
None => return 1.0,
};
let index_metadata = match self.database.get_index(index_name) {
Some(meta) => meta,
None => return 0.33, };
let first_col = match index_metadata.columns.first() {
Some(col) => col,
None => return 0.33,
};
let column_name = match first_col.column_name() {
Some(name) => name,
None => return 0.33, };
let table_name = &index_metadata.table_name;
let table = match self.database.get_table(table_name) {
Some(t) => t,
None => return 0.33,
};
let table_stats = match table.get_statistics() {
Some(stats) if !stats.needs_refresh() => stats,
_ => return 0.33, };
let col_stats = match table_stats.columns.get(column_name) {
Some(stats) => stats,
None => return 0.33,
};
crate::select::scan::index_scan::selection::estimate_selectivity(
where_expr,
column_name,
col_stats,
)
}
pub fn can_use_index(
&self,
index_name: &str,
where_clause: Option<&Expression>,
order_by: Option<&[OrderByItem]>,
) -> bool {
let index_metadata = match self.database.get_index(index_name) {
Some(meta) => meta,
None => return false,
};
if !crate::optimizer::predicate_implication::partial_index_usable(
self.database,
index_name,
where_clause,
) {
return false;
}
let first_col = match index_metadata.columns.first() {
Some(col) => col,
None => return false,
};
let can_use_for_where = where_clause
.map(|expr| {
crate::select::scan::index_scan::selection::index_column_can_filter(expr, first_col)
})
.unwrap_or(false);
let can_use_for_order = order_by
.map(|items| {
crate::select::scan::index_scan::selection::can_use_index_for_order_by(
items,
&index_metadata.columns,
)
})
.unwrap_or(false);
can_use_for_where || can_use_for_order
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_index_planner_new() {
}
}