From 67a080bfa04e77a9de22433062e4b7ec067e7e3e Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 19 Apr 2025 17:53:48 +0300 Subject: [PATCH 01/42] dont mutate where clause during individual index selection phase --- core/translate/optimizer.rs | 57 +++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 38cdb6343..b9e5f5c44 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -278,9 +278,10 @@ fn use_indexes( order_by: &mut Option>, group_by: &Option, ) -> Result<()> { - // Try to use indexes for eliminating ORDER BY clauses - let did_eliminate_orderby = - eliminate_unnecessary_orderby(table_references, available_indexes, order_by, group_by)?; + // // Try to use indexes for eliminating ORDER BY clauses + // let did_eliminate_orderby = + // eliminate_unnecessary_orderby(table_references, available_indexes, order_by, group_by)?; + let did_eliminate_orderby = false; let join_order = table_references .iter() @@ -308,14 +309,22 @@ fn use_indexes( .filter(|i| i.name == index.name) .cloned() .collect::>(); - if let Some(search) = try_extract_index_search_from_where_clause( + if let Some(best_index) = try_extract_index_search_from_where_clause( where_clause, table_index, table_reference, &available_indexes, &join_order, )? { - table_reference.op = Operation::Search(search); + for constraint in best_index.constraints.iter() { + where_clause.remove(constraint.position_in_where_clause.0); + } + table_reference.op = Operation::Search(Search::Seek { + index: best_index.index, + seek_def: best_index + .seek_def + .expect("best_index should have a SeekDef"), + }); } } None => { @@ -348,14 +357,22 @@ fn use_indexes( if let Some(indexes) = available_indexes.get(table_name) { usable_indexes_ref = indexes; } - if let Some(search) = try_extract_index_search_from_where_clause( + if let Some(best_index) = try_extract_index_search_from_where_clause( where_clause, table_index, table_reference, usable_indexes_ref, &join_order, )? { - table_reference.op = Operation::Search(search); + for constraint in best_index.constraints.iter() { + where_clause.remove(constraint.position_in_where_clause.0); + } + table_reference.op = Operation::Search(Search::Seek { + index: best_index.index, + seek_def: best_index + .seek_def + .expect("best_index should have a SeekDef"), + }); } } } @@ -809,8 +826,9 @@ fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { /// Struct used for scoring index scans /// Currently we just estimate cost in a really dumb way, /// i.e. no statistics are used. -struct IndexScore { +pub struct IndexScore { index: Option>, + seek_def: Option, cost: f64, constraints: Vec, } @@ -889,7 +907,7 @@ pub fn try_extract_index_search_from_where_clause( table_reference: &TableReference, table_indexes: &[Arc], join_order: &[JoinOrderMember], -) -> Result> { +) -> Result> { // If there are no WHERE terms, we can't extract a search if where_clause.is_empty() { return Ok(None); @@ -912,6 +930,7 @@ pub fn try_extract_index_search_from_where_clause( let mut constraints_cur = vec![]; let mut best_index = IndexScore { index: None, + seek_def: None, cost: cost_of_full_table_scan, constraints: vec![], }; @@ -979,14 +998,9 @@ pub fn try_extract_index_search_from_where_clause( .cmp(&a.position_in_where_clause.0) }); - for constraint in best_index.constraints.iter() { - where_clause.remove(constraint.position_in_where_clause.0); - } + best_index.seek_def = Some(seek_def); - return Ok(Some(Search::Seek { - index: best_index.index, - seek_def, - })); + return Ok(Some(best_index)); } fn ephemeral_index_estimate_cost( @@ -1331,8 +1345,7 @@ pub fn build_seek_def_from_index_constraints( // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) let (idx, side) = constraint.position_in_where_clause; let where_term = &mut where_clause[idx]; - let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.take_ownership())? - else { + let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { crate::bail_parse_error!("expected binary expression"); }; let cmp_expr = if side == BinaryExprSide::Lhs { @@ -1802,7 +1815,7 @@ pub fn try_extract_rowid_search_expression( if lhs.is_rowid_alias_of(table_index) { match operator { ast::Operator::Equals => { - let rhs_owned = rhs.take_ownership(); + let rhs_owned = rhs.as_ref().clone(); return Ok(Some(Search::RowidEq { cmp_expr: WhereTerm { expr: rhs_owned, @@ -1814,7 +1827,7 @@ pub fn try_extract_rowid_search_expression( | ast::Operator::GreaterEquals | ast::Operator::Less | ast::Operator::LessEquals => { - let rhs_owned = rhs.take_ownership(); + let rhs_owned = rhs.as_ref().clone(); let seek_def = build_seek_def(*operator, iter_dir, vec![(rhs_owned, SortOrder::Asc)])?; return Ok(Some(Search::Seek { @@ -1829,7 +1842,7 @@ pub fn try_extract_rowid_search_expression( if rhs.is_rowid_alias_of(table_index) { match operator { ast::Operator::Equals => { - let lhs_owned = lhs.take_ownership(); + let lhs_owned = lhs.as_ref().clone(); return Ok(Some(Search::RowidEq { cmp_expr: WhereTerm { expr: lhs_owned, @@ -1841,7 +1854,7 @@ pub fn try_extract_rowid_search_expression( | ast::Operator::GreaterEquals | ast::Operator::Less | ast::Operator::LessEquals => { - let lhs_owned = lhs.take_ownership(); + let lhs_owned = lhs.as_ref().clone(); let op = opposite_cmp_op(*operator); let seek_def = build_seek_def(op, iter_dir, vec![(lhs_owned, SortOrder::Asc)])?; From c8c83fc6e6a96f686b869a36d9e5239204b7e9b4 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 21 Apr 2025 16:31:50 +0300 Subject: [PATCH 02/42] OPTIMIZER.MD docs --- core/translate/OPTIMIZER.md | 123 ++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 core/translate/OPTIMIZER.md diff --git a/core/translate/OPTIMIZER.md b/core/translate/OPTIMIZER.md new file mode 100644 index 000000000..cc56e83a5 --- /dev/null +++ b/core/translate/OPTIMIZER.md @@ -0,0 +1,123 @@ +# Overview of the current state of the query optimizer in Limbo + +Query optimization is obviously an important part of any SQL-based database engine. This document is an overview of what we currently do. + +## Join reordering and optimal index selection + +**The goals of query optimization are at least the following:** + +1. Do as little page I/O as possible +2. Do as little CPU work as possible +3. Retain query correctness. + +**The most important ways to achieve #1 and #2 are:** + +1. Choose the optimal access method for each table (e.g. an index or a rowid-based seek, or a full table scan if all else fails). +2. Choose the best or near-best way to reorder the tables in the query so that those optimal access methods can be used. +3. Also factor in whether the chosen join order and indexes allow removal of any sort operations that are necessary for query correctness. + +## Limbo's optimizer + +Limbo's optimizer is an implementation of an extremely traditional [IBM System R](https://www.cs.cmu.edu/~15721-f24/slides/02-Selinger-SystemR-opt.pdf) style optimizer, +i.e. straight from the 70s! The main ideas are: + +1. Find the best (least `cost`) way to access any single table in the query (n=1). Estimate the `output cardinality` (row count) for this table. + - For example, if there is a WHERE clause condition `t1.x = 5` and we have an index on `t1.x`, that index is potentially going to be the best way to access `t1`. Assuming `t1` has `1,000,000` rows, we might estimate that the output cardinality of this will be `10,000` after all the filters on `t1` have been applied. +2. For each result of #1, find the best way to join that result with each other table (n=2). Use the output cardinality of the previous step as the `input cardinality` of this step. +3. For each result of #2, find the best way to join the result of that 2-way join with each other table (n=3) +... +n. Find the best way to join each (n-1)-way join with the remaining table. + +The intermediate steps of the above algorithm are memoized, and finally the join order and access methods with the least cumulative cost is chosen. + +### Estimation of cost and cardinalities + a note on table statistics + +Currently, in the absence of `ANALYZE`, `sqlite_stat1` etc. we assume the following: + +1. Each table has `1,000,000` rows. +2. Each equality (`=`) filter will filter out some percentage of the result set. +3. Each nonequality (e.g. `>`) will filter out some smaller percentage of the result set. +4. Each `4096` byte database page holds `50` rows, i.e. roughly `80` bytes per row +5. Sort operations have some CPU cost dependent on the number of input rows to the sort operation. + +From the above, we derive the following formula for estimating the cost of joining `t1` with `t2` + +``` +JOIN_COST = COST(t1.rows) + t1.rows * COST(t2.rows) + E + +where + COST(rows) = PAGE_IO(rows) + and + E = one-time cost to build ephemeral index if needed (usually 0) +``` + +For example, let's take the query `SELECT * FROM t1 JOIN t2 USING(foo) WHERE t2.foo > 10`. Let's assume the following: + +- `t1` has `6400` rows and `t2` has `8000` rows +- there are no indexes at all +- let's ignore the CPU cost from the equation for simplicity. + +The best access method for both is a full table scan. The output cardinality of `t1` is the full table, because nothing is filtering it. Hence, the cost of `t1 JOIN t2` becomes: + +``` +JOIN_COST = COST(t1.input_rows) + t1.output_rows * COST(t2.input_rows) + +// plugging in the values: + +JOIN_COST = COST(6400) + 6400 * COST(8000) +JOIN_COST = 80 + 6400 * 100 = 640080 +``` + +Now let's consider `t2 JOIN t1`. The best access method for both is still a full scan, but since we can filter on `t2.foo > 10`, its output cardinality decreases. Let's assume only 1/4 of the rows of `t2` match the condition `t2.foo > 10`. Hence, the cost of `t2 join t1` becomes: + +``` +JOIN_COST = COST(t2.input_rows) + t2.output_rows * COST(t1.input_rows) + +// plugging in the values: + +JOIN_COST = COST(8000) + 1/4 * 8000 * COST(6400) +JOIN_COST = 100 + 2000 * 80 = 160100 +``` + +Even though `t2` is a larger table, because we were able to reduce the input set to the join operation, it's dramatically cheaper. + +#### Statistics + +Since we don't support `ANALYZE`, nor can we assume that users will call `ANALYZE` anyway, we use simple magic constants to estimate the selectivity of join predicates, row count of tables, and so on. When we have support for `ANALYZE`, we should plug the statistics from `sqlite_stat1` and friends into the optimizer to make more informed decisions. + +### Estimating the output cardinality of a join + +The output cardinality (output row count) of an operation is as follows: + +``` +OUTPUT_CARDINALITY_JOIN = INPUT_CARDINALITY_RHS * OUTPUT_CARDINALITY_RHS + +where + +INPUT_CARDINALITY_RHS = OUTPUT_CARDINALITY_LHS +``` + +example: + +``` +SELECT * FROM products p JOIN order_lines o ON p.id = o.product_id +``` +Assuming there are 100 products, i.e. just selecting all products would yield 100 rows: + +``` +OUTPUT_CARDINALITY_LHS = 100 +INPUT_CARDINALITY_RHS = 100 +``` + +Assuming p.id = o.product_id will return three orders per each product: + +``` +OUTPUT_CARDINALITY_RHS = 3 + +OUTPUT_CARDINALITY_JOIN = 100 * 3 = 300 +``` +i.e. the join is estimated to return 300 rows, 3 for each product. + +Again, in the absence of statistics, we use magic constants to estimate these cardinalities. + +Estimating them is important because in multi-way joins the output cardinality of the previous join becomes the input cardinality of the next one. \ No newline at end of file From 1e46f1d9debb1a5eff00d68cf84113c2658bd860 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 19 Apr 2025 19:23:01 +0300 Subject: [PATCH 03/42] Feature: join reordering optimizer --- core/translate/main_loop.rs | 4 +- core/translate/optimizer.rs | 2309 +++++++++++++++++++++++++++++++---- core/translate/planner.rs | 4 +- testing/orderby.test | 76 +- 4 files changed, 2126 insertions(+), 267 deletions(-) diff --git a/core/translate/main_loop.rs b/core/translate/main_loop.rs index 4b25474e5..5e8c5908a 100644 --- a/core/translate/main_loop.rs +++ b/core/translate/main_loop.rs @@ -131,9 +131,7 @@ pub fn init_loop( program.emit_insn(Insn::VOpen { cursor_id }); } } - _ => { - unimplemented!() - } + _ => {} }, Operation::Search(search) => { match mode { diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index b9e5f5c44..9e0a019f3 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -4,7 +4,7 @@ use limbo_sqlite3_parser::ast::{self, Expr, SortOrder}; use crate::{ parameters::PARAM_PREFIX, - schema::{Index, IndexColumn, Schema}, + schema::{BTreeTable, Column, Index, IndexColumn, Schema, Type}, translate::plan::TerminationKey, types::SeekOp, util::exprs_are_equivalent, @@ -14,8 +14,8 @@ use crate::{ use super::{ emitter::Resolver, plan::{ - DeletePlan, EvalAt, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, - SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, + DeletePlan, EvalAt, GroupBy, IterationDirection, JoinInfo, JoinOrderMember, Operation, + Plan, Search, SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, }, planner::determine_where_to_eval_expr, }; @@ -43,14 +43,18 @@ fn optimize_select_plan(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { return Ok(()); } - use_indexes( + let best_join_order = use_indexes( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, &mut plan.order_by, - &plan.group_by, + &mut plan.group_by, )?; + if let Some(best_join_order) = best_join_order { + plan.join_order = best_join_order; + } + eliminate_orderby_like_groupby(plan)?; Ok(()) @@ -65,12 +69,12 @@ fn optimize_delete_plan(plan: &mut DeletePlan, schema: &Schema) -> Result<()> { return Ok(()); } - use_indexes( + let _ = use_indexes( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, &mut plan.order_by, - &None, + &mut None, )?; Ok(()) @@ -84,12 +88,12 @@ fn optimize_update_plan(plan: &mut UpdatePlan, schema: &Schema) -> Result<()> { plan.contains_constant_false_condition = true; return Ok(()); } - use_indexes( + let _ = use_indexes( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, &mut plan.order_by, - &None, + &mut None, )?; Ok(()) } @@ -113,7 +117,7 @@ fn eliminate_orderby_like_groupby(plan: &mut SelectPlan) -> Result<()> { } let order_by_clauses = plan.order_by.as_mut().unwrap(); - // TODO: let's make the group by sorter aware of the order by directions so we dont need to skip + // TODO: let's make the group by sorter aware of the order by orders so we dont need to skip // descending terms. if order_by_clauses .iter() @@ -222,10 +226,10 @@ fn eliminate_unnecessary_orderby( // If we found a matching index, use it for scanning *index = Some(matching_index.clone()); - // If the order by direction matches the index direction, we can iterate the index in forwards order. + // If the order by order matches the index order, we can iterate the index in forwards order. // If they don't, we must iterate the index in backwards order. - let index_direction = &matching_index.columns.first().as_ref().unwrap().order; - *iter_dir = match (index_direction, order[0].1) { + let index_order = &matching_index.columns.first().as_ref().unwrap().order; + *iter_dir = match (index_order, order[0].1) { (SortOrder::Asc, SortOrder::Asc) | (SortOrder::Desc, SortOrder::Desc) => { IterationDirection::Forwards } @@ -235,15 +239,15 @@ fn eliminate_unnecessary_orderby( }; // If the index covers all ORDER BY columns, and one of the following applies: - // - the ORDER BY directions exactly match the index orderings, - // - the ORDER by directions are the exact opposite of the index orderings, + // - the ORDER BY orders exactly match the index orderings, + // - the ORDER by orders are the exact opposite of the index orderings, // we can remove the ORDER BY clause. if match_count == order.len() { let full_match = { let mut all_match_forward = true; let mut all_match_reverse = true; - for (i, (_, direction)) in order.iter().enumerate() { - match (&matching_index.columns[i].order, direction) { + for (i, (_, order)) in order.iter().enumerate() { + match (&matching_index.columns[i].order, order) { (SortOrder::Asc, SortOrder::Asc) | (SortOrder::Desc, SortOrder::Desc) => { all_match_reverse = false; } @@ -262,26 +266,682 @@ fn eliminate_unnecessary_orderby( Ok(order_by.is_none()) } -/** - * Use indexes where possible. - * - * When this function is called, condition expressions from both the actual WHERE clause and the JOIN clauses are in the where_clause vector. - * If we find a condition that can be used to index scan, we pop it off from the where_clause vector and put it into a Search operation. - * We put it there simply because it makes it a bit easier to track during translation. - * - * In this function we also try to eliminate ORDER BY clauses if there is an index that satisfies the ORDER BY clause. - */ -fn use_indexes( - table_references: &mut [TableReference], +/// Represents an n-ary join, anywhere from 1 table to N tables. +#[derive(Debug, Clone)] +struct JoinN { + /// Identifiers of the tables in the best_plan + pub table_numbers: Vec, + /// The best access methods for the best_plans + pub best_access_methods: Vec, + /// The estimated number of rows returned by joining these n tables together. + pub output_cardinality: usize, + /// Estimated execution cost of this N-ary join. + pub cost: Cost, +} + +const SELECTIVITY_EQ: f64 = 0.01; +const SELECTIVITY_RANGE: f64 = 0.4; +const SELECTIVITY_OTHER: f64 = 0.9; + +fn join_lhs_tables_to_rhs_table( + lhs: Option<&JoinN>, + rhs_table_number: usize, + rhs_table_reference: &TableReference, + where_clause: &Vec, + indexes_for_table: &[Arc], + join_order: &[JoinOrderMember], + maybe_order_target: Option<&OrderTarget>, + access_methods_cache: &mut HashMap, +) -> Result { + let loop_idx = lhs.map_or(0, |l| l.table_numbers.len()); + // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. + let output_cardinality_multiplier = where_clause + .iter() + .filter(|term| is_potential_index_constraint(term, loop_idx, &join_order)) + .map(|term| { + let ast::Expr::Binary(lhs, op, rhs) = &term.expr else { + return 1.0; + }; + let mut column = if let ast::Expr::Column { table, column, .. } = lhs.as_ref() { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some(&columns[*column]) + } + } else { + None + }; + if column.is_none() { + column = if let ast::Expr::Column { table, column, .. } = rhs.as_ref() { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some(&columns[*column]) + } + } else { + None + } + }; + let Some(column) = column else { + return 1.0; + }; + match op { + ast::Operator::Equals => { + if column.is_rowid_alias || column.primary_key { + 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + } else { + SELECTIVITY_EQ + } + } + ast::Operator::Greater => SELECTIVITY_RANGE, + ast::Operator::GreaterEquals => SELECTIVITY_RANGE, + ast::Operator::Less => SELECTIVITY_RANGE, + ast::Operator::LessEquals => SELECTIVITY_RANGE, + _ => SELECTIVITY_OTHER, + } + }) + .product::(); + + // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. + // If there is an input best_plan on the left, we multiply the input cardinality by the estimated number of rows per table. + let input_cardinality = lhs.map_or(1, |l| l.output_cardinality); + let output_cardinality = (input_cardinality as f64 + * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * output_cardinality_multiplier) + .ceil() as usize; + + // Let's find the best access method and its cost. + // Initialize the best access method to a table scan. + let mut best_access_method = AccessMethod { + // worst case: read all rows of the inner table N times, where N is the number of rows in the outer best_plan + cost: ScanCost { + run_cost: estimate_page_io_cost( + input_cardinality as f64 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, + ), + build_cost: Cost(0.0), + }, + kind: AccessMethodKind::TableScan { + iter_dir: IterationDirection::Forwards, + }, + }; + + let mut rowid_search = None; + for (wi, term) in where_clause.iter().enumerate() { + if let Some(rse) = try_extract_rowid_search_expression( + term, + wi, + loop_idx, + rhs_table_number, + rhs_table_reference, + &join_order, + maybe_order_target, + input_cardinality as f64, + )? { + rowid_search = Some(rse); + } + } + let index_search = try_extract_index_search_from_where_clause( + where_clause, + loop_idx, + rhs_table_number, + rhs_table_reference, + indexes_for_table, + &join_order, + maybe_order_target, + input_cardinality as f64, + )?; + + match (rowid_search, index_search) { + (Some(rowid_search), None) => { + best_access_method = AccessMethod { + cost: rowid_search.cost, + kind: AccessMethodKind::Search { + search: rowid_search.search.expect("search must exist"), + constraints: rowid_search.constraints, + }, + }; + } + (None, Some(index_search)) => { + best_access_method = AccessMethod { + cost: index_search.cost, + kind: if index_search.search.is_some() { + AccessMethodKind::Search { + search: index_search.search.expect("search must exist"), + constraints: index_search.constraints, + } + } else { + AccessMethodKind::IndexScan { + index: index_search.index.expect("index must exist"), + iter_dir: IterationDirection::Forwards, + } + }, + }; + } + (Some(rowid_search), Some(index_search)) => { + if rowid_search.cost.total() < index_search.cost.total() { + best_access_method = AccessMethod { + cost: rowid_search.cost, + kind: AccessMethodKind::Search { + search: rowid_search.search.expect("search must exist"), + constraints: rowid_search.constraints, + }, + }; + } else { + best_access_method = AccessMethod { + cost: index_search.cost, + kind: if index_search.search.is_some() { + AccessMethodKind::Search { + search: index_search.search.expect("search must exist"), + constraints: index_search.constraints, + } + } else { + AccessMethodKind::IndexScan { + index: index_search.index.expect("index must exist"), + iter_dir: IterationDirection::Forwards, + } + }, + }; + } + } + (None, None) => {} + }; + + let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); + let cost = lhs_cost + best_access_method.cost.total(); + + let mut new_numbers = lhs.map_or(vec![rhs_table_number], |l| { + let mut numbers = l.table_numbers.clone(); + numbers.push(rhs_table_number); + numbers + }); + + access_methods_cache.insert(access_methods_cache.len(), best_access_method); + let access_method_idx = access_methods_cache.len() - 1; + Ok(JoinN { + table_numbers: new_numbers, + best_access_methods: lhs.map_or(vec![access_method_idx], |l| { + let mut methods = l.best_access_methods.clone(); + methods.push(access_method_idx); + methods + }), + output_cardinality, + cost, + }) +} + +#[derive(Debug, Clone)] +struct AccessMethod { + // The estimated number of page fetches. + // We are ignoring CPU cost for now. + pub cost: ScanCost, + pub kind: AccessMethodKind, +} + +#[derive(Debug, Clone)] +enum AccessMethodKind { + TableScan { + iter_dir: IterationDirection, + }, + IndexScan { + index: Arc, + iter_dir: IterationDirection, + }, + Search { + search: Search, + constraints: Vec, + }, +} + +/// A bitmask representing which tables are in the join. +/// For example, if the bitmask is 0b1101, then the tables 0, 1, and 3 are in the join. +/// Since this is a mask, the tables aren't ordered. +/// This is used for memoizing the best way to join a subset of N tables. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +struct JoinBitmask(u128); + +impl JoinBitmask { + fn new(init: u128) -> Self { + Self(init) + } + + fn set(&mut self, i: usize) { + self.0 |= 1 << i; + } + + fn intersects(&self, other: &Self) -> bool { + self.0 & other.0 != 0 + } +} + +/// Iterator that generates all possible size k bitmasks for a given number of tables. +/// For example, given: 3 tables and k=2, the bitmasks are: +/// - 0b011 (tables 0, 1) +/// - 0b101 (tables 0, 2) +/// - 0b110 (tables 1, 2) +/// +/// This is used in the dynamic programming approach to finding the best way to join a subset of N tables. +struct JoinBitmaskIter { + current: u128, + max_exclusive: u128, +} + +impl JoinBitmaskIter { + fn new(table_number_max_exclusive: usize, how_many: usize) -> Self { + Self { + current: (1 << how_many) - 1, // Start with smallest k-bit number (e.g., 000111 for k=3) + max_exclusive: 1 << table_number_max_exclusive, + } + } +} + +impl Iterator for JoinBitmaskIter { + type Item = JoinBitmask; + + fn next(&mut self) -> Option { + if self.current >= self.max_exclusive { + return None; + } + + let result = JoinBitmask(self.current); + + // Gosper's hack: compute next k-bit combination in lexicographic order + let c = self.current & (!self.current + 1); // rightmost set bit + let r = self.current + c; // add it to get a carry + let ones = self.current ^ r; // changed bits + let ones = (ones >> 2) / c; // right-adjust shifted bits + self.current = r | ones; // form the next combination + + Some(result) + } +} + +/// Generate all possible bitmasks of size `how_many` for a given number of tables. +fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> JoinBitmaskIter { + JoinBitmaskIter::new(table_number_max_exclusive, how_many) +} + +/// Check if the plan's row iteration order matches the [OrderTarget]'s column order +/// TODO this needs to take iteration order into account, foo vitun bar saatana +fn plan_satisfies_order_target( + plan: &JoinN, + table_references: &[TableReference], + access_methods_cache: &HashMap, + order_target: &OrderTarget, +) -> bool { + let mut target_col_idx = 0; + for table_no in plan.table_numbers.iter() { + let table_ref = &table_references[*table_no]; + // Check if this table has an access method that provides ordering + let access_method = &plan.best_access_methods[target_col_idx]; + let access_method = access_methods_cache.get(access_method).unwrap(); + match &access_method.kind { + AccessMethodKind::IndexScan { index, iter_dir } => { + // The index columns must match the order target columns for this table + for index_col in index.columns.iter() { + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == index_col.order + } else { + target_col.order != index_col.order + }; + if target_col.table_no != *table_no + || target_col.column_no != index_col.pos_in_table + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } + AccessMethodKind::Search { + search: Search::Seek { index, seek_def }, + .. + } => { + if let Some(index) = index { + for index_col in index.columns.iter() { + let target_col = &order_target.0[target_col_idx]; + let order_matches = if seek_def.iter_dir == IterationDirection::Forwards { + target_col.order == index_col.order + } else { + target_col.order != index_col.order + }; + if target_col.table_no != *table_no + || target_col.column_no != index_col.pos_in_table + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } else { + // same as table scan + for i in 0..table_ref.table.columns().len() { + let target_col = &order_target.0[target_col_idx]; + if target_col.table_no != *table_no + || target_col.column_no != i + || target_col.order != SortOrder::Asc + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } + } + AccessMethodKind::Search { + search: Search::RowidEq { .. }, + .. + } => { + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || target_col.order != SortOrder::Asc + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + AccessMethodKind::TableScan { iter_dir } => { + for i in 0..table_ref.table.columns().len() { + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order != SortOrder::Asc + }; + if target_col.table_no != *table_no + || target_col.column_no != i + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } + } + } + false +} + +/// Compute the best way to join a given set of tables. +/// Returns the best [JoinN] if one exists, otherwise returns None. +fn compute_best_join_order( + table_references: &[TableReference], available_indexes: &HashMap>>, - where_clause: &mut Vec, - order_by: &mut Option>, - group_by: &Option, -) -> Result<()> { - // // Try to use indexes for eliminating ORDER BY clauses - // let did_eliminate_orderby = - // eliminate_unnecessary_orderby(table_references, available_indexes, order_by, group_by)?; - let did_eliminate_orderby = false; + where_clause: &Vec, + maybe_order_target: Option, + access_methods_cache: &mut HashMap, +) -> Result> { + if table_references.is_empty() { + return Ok(None); + } + + let n = table_references.len(); + let mut best_plan_memo: HashMap = HashMap::new(); + + // Compute naive left-to-right plan to use as pruning threshold + let naive_plan = compute_naive_left_deep_plan( + table_references, + available_indexes, + where_clause, + maybe_order_target.as_ref(), + access_methods_cache, + )?; + if table_references.len() == 1 { + return Ok(Some(naive_plan)); + } + let mut best_ordered_plan: Option = None; + let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { + plan_satisfies_order_target( + &naive_plan, + table_references, + access_methods_cache, + order_target, + ) + } else { + false + }; + let mut best_plan = naive_plan; + let mut join_order = Vec::with_capacity(n); + join_order.push(JoinOrderMember { + table_no: 0, + is_outer: false, + }); + let cost_upper_bound = best_plan.cost; + let cost_upper_bound_ordered = { + if best_plan_is_also_ordered { + cost_upper_bound + } else { + Cost(f64::MAX) + } + }; + + // Base cases: 1-table subsets + for i in 0..n { + let mut mask = JoinBitmask::new(0); + mask.set(i); + let table_ref = &table_references[i]; + let placeholder = vec![]; + let mut indexes_ref = &placeholder; + if let Some(indexes) = available_indexes.get(table_ref.table.get_name()) { + indexes_ref = indexes; + } + join_order[0] = JoinOrderMember { + table_no: i, + is_outer: false, + }; + assert!(join_order.len() == 1); + let rel = join_lhs_tables_to_rhs_table( + None, + i, + table_ref, + where_clause, + indexes_ref, + &join_order, + maybe_order_target.as_ref(), + access_methods_cache, + )?; + best_plan_memo.insert(mask, rel); + } + join_order.clear(); + + let left_join_illegal_map = { + let left_join_count = table_references + .iter() + .filter(|t| t.join_info.as_ref().map_or(false, |j| j.outer)) + .count(); + if left_join_count == 0 { + None + } else { + // map from rhs table index to lhs table index + let mut left_join_illegal_map: HashMap = + HashMap::with_capacity(left_join_count); + for (i, _) in table_references.iter().enumerate() { + for j in i + 1..table_references.len() { + if table_references[j] + .join_info + .as_ref() + .map_or(false, |j| j.outer) + { + // bitwise OR the masks + if let Some(illegal_lhs) = left_join_illegal_map.get_mut(&i) { + illegal_lhs.set(j); + } else { + left_join_illegal_map.insert(i, JoinBitmask::new(1 << j)); + } + } + } + } + Some(left_join_illegal_map) + } + }; + + // Build larger plans + for subset_size in 2..=n { + for mask in generate_join_bitmasks(n, subset_size) { + let mut best_for_mask: Option = None; + let (mut best_ordered_for_mask, mut best_for_mask_is_also_ordered) = (None, false); + // Try all possible RHS base tables in this mask + for rhs_idx in 0..n { + let rhs_bit = 1 << rhs_idx; + + // Make sure rhs is in this mask + if mask.0 & rhs_bit == 0 { + continue; + } + + // LHS = mask - rhs + let lhs_mask = JoinBitmask(mask.0 ^ rhs_bit); + if lhs_mask.0 == 0 { + continue; + } + + // Skip illegal left join ordering + if let Some(illegal_lhs) = left_join_illegal_map + .as_ref() + .and_then(|deps| deps.get(&rhs_idx)) + { + let legal = !lhs_mask.intersects(illegal_lhs); + if !legal { + continue; // Don't allow RHS before its LEFT in LEFT JOIN + } + } + + let Some(lhs) = best_plan_memo.get(&lhs_mask) else { + continue; + }; + let rhs_ref = &table_references[rhs_idx]; + let placeholder = vec![]; + let mut indexes_ref = &placeholder; + if let Some(indexes) = available_indexes.get(rhs_ref.table.get_name()) { + indexes_ref = indexes; + } + + for table_no in lhs.table_numbers.iter() { + join_order.push(JoinOrderMember { + table_no: *table_no, + is_outer: table_references[*table_no] + .join_info + .as_ref() + .map_or(false, |j| j.outer), + }); + } + join_order.push(JoinOrderMember { + table_no: rhs_idx, + is_outer: table_references[rhs_idx] + .join_info + .as_ref() + .map_or(false, |j| j.outer), + }); + assert!(join_order.len() == subset_size); + + let rel = join_lhs_tables_to_rhs_table( + Some(lhs), + rhs_idx, + rhs_ref, + where_clause, + indexes_ref, + &join_order, + maybe_order_target.as_ref(), + access_methods_cache, + )?; + join_order.clear(); + + if rel.cost >= cost_upper_bound_ordered { + continue; + } + + let satisfies_order_target = if let Some(ref order_target) = maybe_order_target { + plan_satisfies_order_target( + &rel, + table_references, + access_methods_cache, + order_target, + ) + } else { + false + }; + + if rel.cost >= cost_upper_bound { + if !satisfies_order_target { + continue; + } + let existing_ordered_cost: Cost = best_ordered_for_mask + .as_ref() + .map_or(Cost(f64::MAX), |p: &JoinN| p.cost); + if rel.cost < existing_ordered_cost { + best_ordered_for_mask = Some(rel); + } + } else if best_for_mask.is_none() || rel.cost < best_for_mask.as_ref().unwrap().cost + { + best_for_mask = Some(rel); + best_for_mask_is_also_ordered = satisfies_order_target; + } + } + + if let Some(rel) = best_ordered_for_mask.take() { + let cost = rel.cost; + let has_all_tables = mask.0.count_ones() as usize == n; + if has_all_tables && cost_upper_bound_ordered > cost { + best_ordered_plan = Some(rel); + } + } + + if let Some(rel) = best_for_mask.take() { + let cost = rel.cost; + let has_all_tables = mask.0.count_ones() as usize == n; + if has_all_tables { + if cost_upper_bound > cost { + best_plan = rel; + best_plan_is_also_ordered = best_for_mask_is_also_ordered; + } + } else { + best_plan_memo.insert(mask, rel); + } + } + } + } + + Ok(Some(best_plan)) +} + +/// Specialized version of [compute_best_join_order] that just joins tables in the order they are given +/// in the SQL query. This is used as an upper bound for any other plans -- we can give up enumerating +/// permutations if they exceed this cost during enumeration. +fn compute_naive_left_deep_plan( + table_references: &[TableReference], + available_indexes: &HashMap>>, + where_clause: &Vec, + maybe_order_target: Option<&OrderTarget>, + access_methods_cache: &mut HashMap, +) -> Result { + let n = table_references.len(); + assert!(n > 0); let join_order = table_references .iter() @@ -292,110 +952,225 @@ fn use_indexes( }) .collect::>(); - // Try to use indexes for WHERE conditions - for (table_index, table_reference) in table_references.iter_mut().enumerate() { - if matches!(table_reference.op, Operation::Scan { .. }) { - let index = if let Operation::Scan { index, .. } = &table_reference.op { - Option::clone(index) - } else { - None - }; - match index { - // If we decided to eliminate ORDER BY using an index, let's constrain our search to only that index - Some(index) => { - let available_indexes = available_indexes - .values() - .flatten() - .filter(|i| i.name == index.name) - .cloned() - .collect::>(); - if let Some(best_index) = try_extract_index_search_from_where_clause( - where_clause, - table_index, - table_reference, - &available_indexes, - &join_order, - )? { - for constraint in best_index.constraints.iter() { - where_clause.remove(constraint.position_in_where_clause.0); - } - table_reference.op = Operation::Search(Search::Seek { - index: best_index.index, - seek_def: best_index - .seek_def - .expect("best_index should have a SeekDef"), - }); - } - } - None => { - let table_name = table_reference.table.get_name(); + // Start with first table + let placeholder = vec![]; + let mut indexes_ref = &placeholder; + if let Some(indexes) = available_indexes.get(table_references[0].table.get_name()) { + indexes_ref = indexes; + } + let mut best_plan = join_lhs_tables_to_rhs_table( + None, + 0, + &table_references[0], + where_clause, + indexes_ref, + &join_order[..1], + maybe_order_target, + access_methods_cache, + )?; - // If we can utilize the rowid alias of the table, let's preferentially always use it for now. - let mut i = 0; - while i < where_clause.len() { - if let Some(search) = try_extract_rowid_search_expression( - &mut where_clause[i], - table_index, - table_reference, - &join_order, - )? { - where_clause.remove(i); - table_reference.op = Operation::Search(search); - continue; - } else { - i += 1; - } - } - if did_eliminate_orderby && table_index == 0 { - // If we already made the decision to remove ORDER BY based on the Rowid (e.g. ORDER BY id), then skip this. - // It would be possible to analyze the index and see if the covering index would retain the ordering guarantee, - // but we just don't do that yet. - continue; - } - let placeholder = vec![]; - let mut usable_indexes_ref = &placeholder; - if let Some(indexes) = available_indexes.get(table_name) { - usable_indexes_ref = indexes; - } - if let Some(best_index) = try_extract_index_search_from_where_clause( - where_clause, - table_index, - table_reference, - usable_indexes_ref, - &join_order, - )? { - for constraint in best_index.constraints.iter() { - where_clause.remove(constraint.position_in_where_clause.0); - } - table_reference.op = Operation::Search(Search::Seek { - index: best_index.index, - seek_def: best_index - .seek_def - .expect("best_index should have a SeekDef"), - }); - } - } - } - } - - // Finally, if there's no other reason to use an index, if an index covers the columns used in the query, let's use it. - if let Some(indexes) = available_indexes.get(table_reference.table.get_name()) { - for index_candidate in indexes.iter() { - let is_covering = table_reference.index_is_covering(index_candidate); - if let Operation::Scan { index, .. } = &mut table_reference.op { - if index.is_some() { - continue; - } - if is_covering { - *index = Some(index_candidate.clone()); - break; - } - } - } + // Add remaining tables one at a time from left to right + for i in 1..n { + let mut indexes_ref = &placeholder; + if let Some(indexes) = available_indexes.get(table_references[i].table.get_name()) { + indexes_ref = indexes; } + best_plan = join_lhs_tables_to_rhs_table( + Some(&best_plan), + i, + &table_references[i], + where_clause, + indexes_ref, + &join_order[..i + 1], + maybe_order_target, + access_methods_cache, + )?; } - Ok(()) + Ok(best_plan) +} + +struct ColumnOrder { + table_no: usize, + column_no: usize, + order: SortOrder, +} + +struct OrderTarget(Vec); + +impl OrderTarget { + fn maybe_from_iterator<'a>( + list: impl Iterator + Clone, + ) -> Option { + if list.clone().count() == 0 { + return None; + } + if list + .clone() + .any(|(expr, _)| !matches!(expr, ast::Expr::Column { .. })) + { + return None; + } + Some(OrderTarget( + list.map(|(expr, order)| { + let ast::Expr::Column { table, column, .. } = expr else { + unreachable!(); + }; + ColumnOrder { + table_no: *table, + column_no: *column, + order, + } + }) + .collect(), + )) + } +} + +/// Compute an [OrderTarget] for the join optimizer to use. +/// Ideally, a join order is both efficient in joining the tables +/// but also returns the results in an order that minimizes the amount of +/// sorting that needs to be done later (either in GROUP BY, ORDER BY, or both). +fn compute_order_target( + order_by: &Option>, + group_by: Option<&mut GroupBy>, +) -> Option { + match (order_by, group_by) { + // No ordering demands - we don't care what order the joined result rows are in + (None, None) => None, + // Only ORDER BY - we would like the joined result rows to be in the order specified by the ORDER BY + (Some(order_by), None) => { + OrderTarget::maybe_from_iterator(order_by.iter().map(|(expr, order)| (expr, *order))) + } + // Only GROUP BY - we would like the joined result rows to be in the order specified by the GROUP BY + (None, Some(group_by)) => OrderTarget::maybe_from_iterator( + group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + ), + // Both ORDER BY and GROUP BY: + // If the GROUP BY does not contain all the expressions in the ORDER BY, + // then we must separately sort the result rows for ORDER BY anyway. + // However, in that case we can use the GROUP BY expressions as the target order for the join, + // so that we don't have to sort twice. + // + // If the GROUP BY contains all the expressions in the ORDER BY, + // then we again can use the GROUP BY expressions as the target order for the join; + // however in this case we must take the ASC/DESC from ORDER BY into account. + (Some(order_by), Some(group_by)) => { + // Does the group by contain all expressions in the order by? + let group_by_contains_all = group_by.exprs.iter().all(|expr| { + order_by + .iter() + .any(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) + }); + // If not, let's try to target an ordering that matches the group by -- we don't care about ASC/DESC + if !group_by_contains_all { + return OrderTarget::maybe_from_iterator( + group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + ); + } + // If yes, let's try to target an ordering that matches the GROUP BY columns, + // but the ORDER BY orderings. First, we need to reorder the GROUP BY columns to match the ORDER BY columns. + group_by.exprs.sort_by_key(|expr| { + order_by + .iter() + .position(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) + .map_or(usize::MAX, |i| i) + }); + // Iterate over GROUP BY, but take the ORDER BY orderings into account. + OrderTarget::maybe_from_iterator( + group_by + .exprs + .iter() + .zip( + order_by + .iter() + .map(|(_, dir)| dir) + .chain(std::iter::repeat(&SortOrder::Asc)), + ) + .map(|(expr, dir)| (expr, *dir)), + ) + } + } +} + +fn use_indexes( + table_references: &mut [TableReference], + available_indexes: &HashMap>>, + where_clause: &mut Vec, + order_by: &mut Option>, + group_by: &mut Option, +) -> Result>> { + let mut access_methods_cache = HashMap::new(); + let maybe_order_target = compute_order_target(order_by, group_by.as_mut()); + let Some(best) = compute_best_join_order( + table_references, + available_indexes, + where_clause, + maybe_order_target, + &mut access_methods_cache, + )? + else { + return Ok(None); + }; + + let (mut best_access_methods, best_table_numbers) = { + let mut kinds = Vec::with_capacity(best.best_access_methods.len()); + for am_idx in best.best_access_methods.iter() { + // take value from cache + let am = access_methods_cache.remove(am_idx).unwrap(); + kinds.push(am.kind); + } + (kinds, best.table_numbers) + }; + let mut to_remove_from_where_clause = vec![]; + for table_number in best_table_numbers.iter().rev() { + let access_method = best_access_methods.pop().unwrap(); + if matches!( + table_references[*table_number].op, + Operation::Subquery { .. } + ) { + // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. + assert!( + matches!(access_method, AccessMethodKind::TableScan { .. }), + "nothing in the current optimizer should be able to optimize subqueries" + ); + continue; + } + table_references[*table_number].op = match access_method { + AccessMethodKind::TableScan { iter_dir } => Operation::Scan { + iter_dir, + index: None, + }, + AccessMethodKind::IndexScan { index, iter_dir } => Operation::Scan { + iter_dir, + index: Some(index), + }, + AccessMethodKind::Search { + search, + constraints, + } => { + for constraint in constraints.iter() { + to_remove_from_where_clause.push(constraint.position_in_where_clause.0); + } + Operation::Search(search) + } + }; + } + to_remove_from_where_clause.sort_by_key(|c| *c); + for position in to_remove_from_where_clause.iter().rev() { + where_clause.remove(*position); + } + let best_join_order = best_table_numbers + .into_iter() + .map(|table_number| JoinOrderMember { + table_no: table_number, + is_outer: table_references[table_number] + .join_info + .as_ref() + .map_or(false, |join_info| join_info.outer), + }) + .collect(); + Ok(Some(best_join_order)) } #[derive(Debug, PartialEq, Clone)] @@ -823,47 +1598,104 @@ fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { } } -/// Struct used for scoring index scans +/// Struct used for scoring index candidates /// Currently we just estimate cost in a really dumb way, /// i.e. no statistics are used. -pub struct IndexScore { +#[derive(Debug, Clone)] +pub struct IndexCandidate { + /// The index that we are considering. Can be None e.g. in case of table scan or rowid-based search. index: Option>, - seek_def: Option, - cost: f64, + /// The search that we are considering, e.g. an index seek. Can be None if it's a table-scan or index-scan with no seek. + search: Option, + /// The direction of iteration. + iter_dir: IterationDirection, + /// The estimated cost of the scan or seek. + cost: ScanCost, + /// The constraints involved -- these are tracked so they can be removed from the where clause if this candidate is selected. constraints: Vec, } +/// A simple newtype wrapper over a f64 that represents the cost of an operation. +/// +/// This is used to estimate the cost of scans, seeks, and joins. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Cost(pub f64); + +impl std::ops::Add for Cost { + type Output = Cost; + + fn add(self, other: Cost) -> Cost { + Cost(self.0 + other.0) + } +} + +impl std::ops::Deref for Cost { + type Target = f64; + + fn deref(&self) -> &f64 { + &self.0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct IndexInfo { unique: bool, column_count: usize, + covering: bool, } -const ESTIMATED_HARDCODED_ROWS_PER_TABLE: f64 = 1000.0; +#[derive(Debug, Clone, Copy, PartialEq)] +struct ScanCost { + run_cost: Cost, + build_cost: Cost, +} -/// Unbelievably dumb cost estimate for rows scanned by an index scan. -fn dumb_cost_estimator( +impl std::ops::Add for ScanCost { + type Output = ScanCost; + + fn add(self, other: ScanCost) -> ScanCost { + ScanCost { + run_cost: self.run_cost + other.run_cost, + build_cost: self.build_cost + other.build_cost, + } + } +} + +impl ScanCost { + pub fn total(&self) -> Cost { + self.run_cost + self.build_cost + } +} + +const ESTIMATED_HARDCODED_ROWS_PER_TABLE: usize = 1000000; +const ESTIMATED_HARDCODED_ROWS_PER_PAGE: usize = 50; // roughly 80 bytes per 4096 byte page + +fn estimate_page_io_cost(rowcount: f64) -> Cost { + Cost((rowcount as f64 / ESTIMATED_HARDCODED_ROWS_PER_PAGE as f64).ceil()) +} + +/// Estimate the cost of a scan or seek operation. +/// +/// This is a very simple model that estimates the number of pages read +/// based on the number of rows read, ignoring any CPU costs. +fn estimate_cost_for_scan_or_seek( index_info: Option, constraints: &[IndexConstraint], - is_inner_loop: bool, is_ephemeral: bool, -) -> f64 { - // assume that the outer table always does a full table scan :) - // this discourages building ephemeral indexes on the outer table - // (since a scan reads TABLE_ROWS rows, so an ephemeral index on the outer table would both read TABLE_ROWS rows to build the index and then seek the index) - // but encourages building it on the inner table because it's only built once but the inner loop is run as many times as the outer loop has iterations. - let loop_multiplier = if is_inner_loop { - ESTIMATED_HARDCODED_ROWS_PER_TABLE + input_cardinality: f64, +) -> ScanCost { + let build_cost = if is_ephemeral { + estimate_page_io_cost(2.0 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64) } else { - 1.0 + Cost(0.0) }; - - // If we are building an ephemeral index, we assume we will scan the entire source table to build it. - // Non-ephemeral indexes don't need to be built. - let cost_to_build_index = is_ephemeral as usize as f64 * ESTIMATED_HARDCODED_ROWS_PER_TABLE; - let Some(index_info) = index_info else { - return cost_to_build_index + ESTIMATED_HARDCODED_ROWS_PER_TABLE * loop_multiplier; + return ScanCost { + run_cost: estimate_page_io_cost( + input_cardinality * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, + ), + build_cost, + }; }; let final_constraint_is_range = constraints @@ -878,7 +1710,7 @@ fn dumb_cost_estimator( }) .count() as f64; - let selectivity = match ( + let cost_multiplier = match ( index_info.unique, index_info.column_count as f64, equalities_count, @@ -892,33 +1724,45 @@ fn dumb_cost_estimator( } } // on an unique index if we have equalities across all index columns, assume very high selectivity - (true, index_cols, eq_count) if eq_count == index_cols => 0.01 * eq_count, + (true, index_cols, eq_count) if eq_count == index_cols => 0.01, + (false, index_cols, eq_count) if eq_count == index_cols => 0.1, // some equalities: let's assume each equality has a selectivity of 0.1 and range query selectivity is 0.4 - (_, _, eq_count) => (eq_count * 0.1) * if final_constraint_is_range { 0.4 } else { 1.0 }, + (_, _, eq_count) => { + let mut multiplier = 1.0; + for _ in 0..(eq_count as usize) { + multiplier *= 0.1; + } + multiplier * if final_constraint_is_range { 4.0 } else { 1.0 } + } }; - cost_to_build_index + selectivity * ESTIMATED_HARDCODED_ROWS_PER_TABLE * loop_multiplier + + // little bonus for covering indexes + let covering_multiplier = if index_info.covering { 0.9 } else { 1.0 }; + + let cost = estimate_page_io_cost( + cost_multiplier + * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * input_cardinality + * covering_multiplier, + ); + ScanCost { + run_cost: cost, + build_cost, + } } /// Try to extract an index search from the WHERE clause /// Returns an optional [Search] struct if an index search can be extracted, otherwise returns None. pub fn try_extract_index_search_from_where_clause( - where_clause: &mut Vec, + where_clause: &[WhereTerm], + loop_index: usize, table_index: usize, table_reference: &TableReference, table_indexes: &[Arc], join_order: &[JoinOrderMember], -) -> Result> { - // If there are no WHERE terms, we can't extract a search - if where_clause.is_empty() { - return Ok(None); - } - - let iter_dir = if let Operation::Scan { iter_dir, .. } = &table_reference.op { - *iter_dir - } else { - return Ok(None); - }; - + maybe_order_target: Option<&OrderTarget>, + input_cardinality: f64, +) -> Result> { // Find all potential index constraints // For WHERE terms to be used to constrain an index scan, they must: // 1. refer to columns in the table that the index is on @@ -926,11 +1770,13 @@ pub fn try_extract_index_search_from_where_clause( // 3. constrain the index columns in the order that they appear in the index // - e.g. if the index is on (a,b,c) then we can use all of "a = 1 AND b = 2 AND c = 3" to constrain the index scan, // - but if the where clause is "a = 1 and c = 3" then we can only use "a = 1". - let cost_of_full_table_scan = dumb_cost_estimator(None, &[], table_index != 0, false); + let cost_of_full_table_scan = + estimate_cost_for_scan_or_seek(None, &[], false, input_cardinality); let mut constraints_cur = vec![]; - let mut best_index = IndexScore { + let mut best_index = IndexCandidate { index: None, - seek_def: None, + search: None, + iter_dir: IterationDirection::Forwards, cost: cost_of_full_table_scan, constraints: vec![], }; @@ -939,6 +1785,7 @@ pub fn try_extract_index_search_from_where_clause( // Check how many terms in the where clause constrain the index in column order find_index_constraints( where_clause, + loop_index, table_index, index, join_order, @@ -946,16 +1793,17 @@ pub fn try_extract_index_search_from_where_clause( )?; // naive scoring since we don't have statistics: prefer the index where we can use the most columns // e.g. if we can use all columns of an index on (a,b), it's better than an index of (c,d,e) where we can only use c. - let cost = dumb_cost_estimator( + let cost = estimate_cost_for_scan_or_seek( Some(IndexInfo { unique: index.unique, + covering: table_reference.index_is_covering(index.as_ref()), column_count: index.columns.len(), }), &constraints_cur, - table_index != 0, false, + input_cardinality, ); - if cost < best_index.cost { + if cost.total() < best_index.cost.total() { best_index.index = Some(Arc::clone(index)); best_index.cost = cost; best_index.constraints.clear(); @@ -967,8 +1815,15 @@ pub fn try_extract_index_search_from_where_clause( // let's see if building an ephemeral index would be better. if best_index.index.is_none() { let (ephemeral_cost, constraints_with_col_idx, mut constraints_without_col_idx) = - ephemeral_index_estimate_cost(where_clause, table_reference, table_index, join_order); - if ephemeral_cost < best_index.cost { + ephemeral_index_estimate_cost( + where_clause, + table_reference, + loop_index, + table_index, + join_order, + input_cardinality, + ); + if ephemeral_cost.total() < best_index.cost.total() { // ephemeral index makes sense, so let's build it now. // ephemeral columns are: columns from the table_reference, constraints first, then the rest let ephemeral_index = @@ -986,6 +1841,35 @@ pub fn try_extract_index_search_from_where_clause( return Ok(None); } + if best_index.constraints.is_empty() { + return Ok(Some(best_index)); + } + + let iter_dir = if let Some(order_target) = maybe_order_target { + // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards + let index = best_index.index.as_ref().unwrap(); + let mut should_use_backwards = true; + for i in 0..order_target.0.len().min(index.columns.len()) { + if order_target.0[i].table_no != table_index + || order_target.0[i].column_no != index.columns[i].pos_in_table + { + should_use_backwards = false; + break; + } + if order_target.0[i].order == index.columns[i].order { + should_use_backwards = false; + break; + } + } + if should_use_backwards { + IterationDirection::Backwards + } else { + IterationDirection::Forwards + } + } else { + IterationDirection::Forwards + }; + // Build the seek definition let seek_def = build_seek_def_from_index_constraints(&best_index.constraints, iter_dir, where_clause)?; @@ -998,21 +1882,30 @@ pub fn try_extract_index_search_from_where_clause( .cmp(&a.position_in_where_clause.0) }); - best_index.seek_def = Some(seek_def); + best_index.search = Some(Search::Seek { + index: best_index.index.as_ref().cloned(), + seek_def, + }); return Ok(Some(best_index)); } fn ephemeral_index_estimate_cost( - where_clause: &mut Vec, + where_clause: &[WhereTerm], table_reference: &TableReference, + loop_index: usize, table_index: usize, join_order: &[JoinOrderMember], -) -> (f64, Vec<(usize, IndexConstraint)>, Vec) { + input_cardinality: f64, +) -> ( + ScanCost, + Vec<(usize, IndexConstraint)>, + Vec, +) { let mut constraints_with_col_idx: Vec<(usize, IndexConstraint)> = where_clause .iter() .enumerate() - .filter(|(_, term)| is_potential_index_constraint(term, table_index, join_order)) + .filter(|(_, term)| is_potential_index_constraint(term, loop_index, join_order)) .filter_map(|(i, term)| { let Ok(ast::Expr::Binary(lhs, operator, rhs)) = unwrap_parens(&term.expr) else { panic!("expected binary expression"); @@ -1059,6 +1952,16 @@ fn ephemeral_index_estimate_cost( .position(|c| c.1.operator != ast::Operator::Equals) .unwrap_or(constraints_with_col_idx.len()), ); + if constraints_with_col_idx.is_empty() { + return ( + ScanCost { + run_cost: Cost(0.0), + build_cost: Cost(f64::MAX), + }, + vec![], + vec![], + ); + } let ephemeral_column_count = table_reference .columns() @@ -1072,14 +1975,15 @@ fn ephemeral_index_estimate_cost( .cloned() .map(|(_, c)| c) .collect::>(); - let ephemeral_cost = dumb_cost_estimator( + let ephemeral_cost = estimate_cost_for_scan_or_seek( Some(IndexInfo { unique: false, column_count: ephemeral_column_count, + covering: false, }), &constraints_without_col_idx, - table_index != 0, true, + input_cardinality, ); ( ephemeral_cost, @@ -1219,11 +2123,11 @@ fn get_column_position_in_index( fn is_potential_index_constraint( term: &WhereTerm, - table_index: usize, + loop_index: usize, join_order: &[JoinOrderMember], ) -> bool { // Skip terms that cannot be evaluated at this table's loop level - if !term.should_eval_at_loop(table_index, join_order) { + if !term.should_eval_at_loop(loop_index, join_order) { return false; } // Skip terms that are not binary comparisons @@ -1254,7 +2158,7 @@ fn is_potential_index_constraint( let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { return false; }; - if eval_at_left == EvalAt::Loop(table_index) && eval_at_right == EvalAt::Loop(table_index) { + if eval_at_left == EvalAt::Loop(loop_index) && eval_at_right == EvalAt::Loop(loop_index) { return false; } true @@ -1265,7 +2169,8 @@ fn is_potential_index_constraint( /// E.g. for index (a,b,c) to be fully used, there must be a [WhereTerm] for each of a, b, and c. /// If e.g. only a and c are present, then only the first column 'a' of the index will be used. fn find_index_constraints( - where_clause: &mut Vec, + where_clause: &[WhereTerm], + loop_index: usize, table_index: usize, index: &Arc, join_order: &[JoinOrderMember], @@ -1274,7 +2179,7 @@ fn find_index_constraints( for position_in_index in 0..index.columns.len() { let mut found = false; for (position_in_where_clause, term) in where_clause.iter().enumerate() { - if !is_potential_index_constraint(term, table_index, join_order) { + if !is_potential_index_constraint(term, loop_index, join_order) { continue; } @@ -1332,7 +2237,7 @@ fn find_index_constraints( pub fn build_seek_def_from_index_constraints( constraints: &[IndexConstraint], iter_dir: IterationDirection, - where_clause: &mut Vec, + where_clause: &[WhereTerm], ) -> Result { assert!( !constraints.is_empty(), @@ -1344,7 +2249,7 @@ pub fn build_seek_def_from_index_constraints( for constraint in constraints { // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) let (idx, side) = constraint.position_in_where_clause; - let where_term = &mut where_clause[idx]; + let where_term = &where_clause[idx]; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { crate::bail_parse_error!("expected binary expression"); }; @@ -1373,7 +2278,7 @@ pub fn build_seek_def_from_index_constraints( /// 2. The [TerminationKey], which specifies the key that we will use to terminate the index scan that follows the seek. /// /// There are some nuances to how, and which parts of, the index key can be used in the [SeekKey] and [TerminationKey], -/// depending on the operator and iteration direction. This function explains those nuances inline when dealing with +/// depending on the operator and iteration order. This function explains those nuances inline when dealing with /// each case. /// /// But to illustrate the general idea, consider the following examples: @@ -1514,7 +2419,7 @@ fn build_seek_def( // Termination key: end at the first GE(x:10, y:20), e.g. (x=10, y=20) // // Descending index example: (x=10 AND y<20) - // Seek key: start from the first LT(x:10, y:20), e.g. (x=10, y=19), so reversed -> GT(x:10, y:20) + // Seek key: start from the first LT(x:10, y:20), e.g. (x=10, y=19) so reversed -> GT(x:10, y:20) // Termination key: end at the first LT(x:10), e.g. (x=9, y=usize::MAX), so reversed -> GE(x:10, NULL); i.e. GE the smallest possible (x=10, y) combination (NULL is always LT) (IterationDirection::Forwards, ast::Operator::Less) => { let (seek_key_len, termination_key_len, seek_op, termination_op) = @@ -1787,39 +2692,74 @@ fn build_seek_def( } pub fn try_extract_rowid_search_expression( - cond: &mut WhereTerm, - table_index: usize, + cond: &WhereTerm, + cond_idx: usize, + loop_idx: usize, + table_idx: usize, table_reference: &TableReference, join_order: &[JoinOrderMember], -) -> Result> { - let iter_dir = if let Operation::Scan { iter_dir, .. } = &table_reference.op { - *iter_dir - } else { - return Ok(None); - }; - if !cond.should_eval_at_loop(table_index, join_order) { + maybe_order_target: Option<&OrderTarget>, + input_cardinality: f64, +) -> Result> { + if !cond.should_eval_at_loop(loop_idx, join_order) { return Ok(None); } - match &mut cond.expr { + let iter_dir = if let Some(order_target) = maybe_order_target { + // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards + let rowid_alias_column_no = table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + + let should_use_backwards = if let Some(rowid_alias_column_no) = rowid_alias_column_no { + order_target.0.len() == 1 + && order_target.0[0].table_no == table_idx + && order_target.0[0].column_no == rowid_alias_column_no + && order_target.0[0].order == SortOrder::Desc + } else { + false + }; + if should_use_backwards { + IterationDirection::Backwards + } else { + IterationDirection::Forwards + } + } else { + IterationDirection::Forwards + }; + match &cond.expr { ast::Expr::Binary(lhs, operator, rhs) => { // If both lhs and rhs refer to columns from this table, we can't perform a rowid seek // Examples: // - WHERE t.x > t.y // - WHERE t.x + 1 > t.y - 5 // - WHERE t.x = (t.x) - if determine_where_to_eval_expr(lhs, join_order)? == EvalAt::Loop(table_index) - && determine_where_to_eval_expr(rhs, join_order)? == EvalAt::Loop(table_index) + if determine_where_to_eval_expr(lhs, join_order)? == EvalAt::Loop(loop_idx) + && determine_where_to_eval_expr(rhs, join_order)? == EvalAt::Loop(loop_idx) { return Ok(None); } - if lhs.is_rowid_alias_of(table_index) { + if lhs.is_rowid_alias_of(table_idx) { match operator { ast::Operator::Equals => { let rhs_owned = rhs.as_ref().clone(); - return Ok(Some(Search::RowidEq { - cmp_expr: WhereTerm { - expr: rhs_owned, - from_outer_join: cond.from_outer_join, + return Ok(Some(IndexCandidate { + index: None, + iter_dir, + constraints: vec![IndexConstraint { + position_in_where_clause: (cond_idx, BinaryExprSide::Rhs), + operator: *operator, + index_column_sort_order: SortOrder::Asc, + }], + search: Some(Search::RowidEq { + cmp_expr: WhereTerm { + expr: rhs_owned, + from_outer_join: cond.from_outer_join, + }, + }), + cost: ScanCost { + run_cost: estimate_page_io_cost(3.0 * input_cardinality), // assume 3 page IOs to perform a seek + build_cost: Cost(0.0), }, })); } @@ -1828,25 +2768,56 @@ pub fn try_extract_rowid_search_expression( | ast::Operator::Less | ast::Operator::LessEquals => { let rhs_owned = rhs.as_ref().clone(); + let range_selectivity = SELECTIVITY_RANGE; let seek_def = build_seek_def(*operator, iter_dir, vec![(rhs_owned, SortOrder::Asc)])?; - return Ok(Some(Search::Seek { + return Ok(Some(IndexCandidate { index: None, - seek_def, + iter_dir, + constraints: vec![IndexConstraint { + position_in_where_clause: (cond_idx, BinaryExprSide::Rhs), + operator: *operator, + index_column_sort_order: SortOrder::Asc, + }], + search: Some(Search::Seek { + index: None, + seek_def, + }), + cost: ScanCost { + run_cost: estimate_page_io_cost( + ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * range_selectivity + * input_cardinality, + ), + build_cost: Cost(0.0), + }, })); } _ => {} } } - if rhs.is_rowid_alias_of(table_index) { + if rhs.is_rowid_alias_of(table_idx) { match operator { ast::Operator::Equals => { let lhs_owned = lhs.as_ref().clone(); - return Ok(Some(Search::RowidEq { - cmp_expr: WhereTerm { - expr: lhs_owned, - from_outer_join: cond.from_outer_join, + return Ok(Some(IndexCandidate { + index: None, + iter_dir, + constraints: vec![IndexConstraint { + position_in_where_clause: (cond_idx, BinaryExprSide::Lhs), + operator: *operator, + index_column_sort_order: SortOrder::Asc, + }], + search: Some(Search::RowidEq { + cmp_expr: WhereTerm { + expr: lhs_owned, + from_outer_join: cond.from_outer_join, + }, + }), + cost: ScanCost { + run_cost: estimate_page_io_cost(3.0 * input_cardinality), // assume 3 page IOs to perform a seek + build_cost: Cost(0.0), }, })); } @@ -1856,11 +2827,29 @@ pub fn try_extract_rowid_search_expression( | ast::Operator::LessEquals => { let lhs_owned = lhs.as_ref().clone(); let op = opposite_cmp_op(*operator); + let range_selectivity = SELECTIVITY_RANGE; let seek_def = build_seek_def(op, iter_dir, vec![(lhs_owned, SortOrder::Asc)])?; - return Ok(Some(Search::Seek { + return Ok(Some(IndexCandidate { index: None, - seek_def, + iter_dir, + constraints: vec![IndexConstraint { + position_in_where_clause: (cond_idx, BinaryExprSide::Lhs), + operator: *operator, + index_column_sort_order: SortOrder::Asc, + }], + search: Some(Search::Seek { + index: None, + seek_def, + }), + cost: ScanCost { + run_cost: estimate_page_io_cost( + ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * range_selectivity + * input_cardinality, + ), + build_cost: Cost(0.0), + }, })); } _ => {} @@ -2011,3 +3000,875 @@ impl TakeOwnership for ast::Expr { std::mem::replace(self, ast::Expr::Literal(ast::Literal::Null)) } } + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use limbo_sqlite3_parser::ast::Operator; + + use super::*; + use crate::{ + schema::{BTreeTable, Column, Table, Type}, + translate::plan::{ColumnUsedMask, JoinInfo}, + }; + + #[test] + fn test_generate_bitmasks() { + let bitmasks = generate_join_bitmasks(4, 2).collect::>(); + assert!(bitmasks.contains(&JoinBitmask(0b11))); // {0,1} + assert!(bitmasks.contains(&JoinBitmask(0b101))); // {0,2} + assert!(bitmasks.contains(&JoinBitmask(0b110))); // {1,2} + assert!(bitmasks.contains(&JoinBitmask(0b1001))); // {0,3} + assert!(bitmasks.contains(&JoinBitmask(0b1010))); // {1,3} + assert!(bitmasks.contains(&JoinBitmask(0b1100))); // {2,3} + } + + #[test] + /// Test that [compute_best_join_order] returns None when there are no table references. + fn test_compute_best_join_order_empty() { + let table_references = vec![]; + let available_indexes = HashMap::new(); + let where_clause = vec![]; + + let mut access_methods_cache = HashMap::new(); + + let result = compute_best_join_order( + &table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + /// Test that [compute_best_join_order] returns a table scan access method when the where clause is empty. + fn test_compute_best_join_order_single_table_no_indexes() { + let t1 = _create_btree_table("test_table", _create_column_list(&["id"], Type::Integer)); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + let available_indexes = HashMap::new(); + let where_clause = vec![]; + + let mut access_methods_cache = HashMap::new(); + + // SELECT * from test_table + // expecting best_best_plan() not to do any work due to empty where clause. + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap() + .unwrap(); + /// Should just be a table scan access method + assert!(matches!( + access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if iter_dir == IterationDirection::Forwards + )); + } + + #[test] + /// Test that [compute_best_join_order] returns a RowidEq access method when the where clause has an EQ constraint on the rowid alias. + fn test_compute_best_join_order_single_table_rowid_eq() { + let t1 = _create_btree_table("test_table", vec![_create_column_rowid_alias("id")]); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, true), // table 0, column 0 (rowid) + ast::Operator::Equals, + _create_numeric_literal("42"), + )]; + + let mut access_methods_cache = HashMap::new(); + + // SELECT * FROM test_table WHERE id = 42 + // expecting a RowidEq access method because id is a rowid alias. + let result = compute_best_join_order( + &table_references, + &HashMap::new(), + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![0]); + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + search: Search::RowidEq { cmp_expr }, + constraints, + } + if &cmp_expr.expr == &_create_numeric_literal("42") && constraints.len() == 1 && constraints[0].position_in_where_clause == (0, BinaryExprSide::Rhs), + ), + "expected rowid eq access method, got {:?}", + access_methods_cache[&best_plan.best_access_methods[0]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] returns an IndexScan access method when the where clause has an EQ constraint on a primary key. + fn test_compute_best_join_order_single_table_pk_eq() { + let t1 = _create_btree_table( + "test_table", + vec![_create_column_of_type("id", Type::Integer)], + ); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, false), // table 0, column 0 (id) + ast::Operator::Equals, + _create_numeric_literal("42"), + )]; + + let mut access_methods_cache = HashMap::new(); + + let mut available_indexes = HashMap::new(); + let index = Arc::new(Index { + name: "sqlite_autoindex_test_table_1".to_string(), + table_name: "test_table".to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert("test_table".to_string(), vec![index]); + + // SELECT * FROM test_table WHERE id = 42 + // expecting an IndexScan access method because id is a primary key with an index + let result = compute_best_join_order( + &table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![0]); + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + constraints, + } + if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "sqlite_autoindex_test_table_1") + ), + "expected index search access method, got {:?}", + access_methods_cache[&best_plan.best_access_methods[0]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] moves the outer table to the inner position when an index can be used on it, but not the original inner table. + fn test_compute_best_join_order_two_tables() { + let t1 = _create_btree_table("table1", _create_column_list(&["id"], Type::Integer)); + let t2 = _create_btree_table("table2", _create_column_list(&["id"], Type::Integer)); + + let mut table_references = vec![ + _create_table_reference(t1.clone(), None), + _create_table_reference( + t2.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + let mut available_indexes = HashMap::new(); + // Index on the outer table (table1) + let index1 = Arc::new(Index { + name: "index1".to_string(), + table_name: "table1".to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert("table1".to_string(), vec![index1]); + + // SELECT * FROM table1 JOIN table2 WHERE table1.id = table2.id + // expecting table2 to be chosen first due to the index on table1.id + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, false), // table1.id + ast::Operator::Equals, + _create_column_expr(1, 0, false), // table2.id + )]; + + let mut access_methods_cache = HashMap::new(); + + let result = compute_best_join_order( + &mut table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![1, 0]); + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "expected TableScan access method, got {:?}", + access_methods_cache[&best_plan.best_access_methods[0]].kind + ); + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + constraints, + } + if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "index1") + ), + "expected Search access method, got {:?}", + access_methods_cache[&best_plan.best_access_methods[1]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] returns a sensible order and plan for three tables, each with indexes. + fn test_compute_best_join_order_three_tables_indexed() { + let table_orders = _create_btree_table( + "orders", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("customer_id", Type::Integer), + _create_column_of_type("total", Type::Integer), + ], + ); + let table_customers = _create_btree_table( + "customers", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("name", Type::Integer), + ], + ); + let table_order_items = _create_btree_table( + "order_items", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("order_id", Type::Integer), + _create_column_of_type("product_id", Type::Integer), + _create_column_of_type("quantity", Type::Integer), + ], + ); + + let table_references = vec![ + _create_table_reference(table_orders.clone(), None), + _create_table_reference( + table_customers.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + _create_table_reference( + table_order_items.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + const TABLE_NO_ORDERS: usize = 0; + const TABLE_NO_CUSTOMERS: usize = 1; + const TABLE_NO_ORDER_ITEMS: usize = 2; + + let mut available_indexes = HashMap::new(); + ["orders", "customers", "order_items"] + .iter() + .for_each(|table_name| { + // add primary key index called sqlite_autoindex__1 + let index_name = format!("sqlite_autoindex_{}_1", table_name); + let index = Arc::new(Index { + name: index_name, + table_name: table_name.to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert(table_name.to_string(), vec![index]); + }); + let customer_id_idx = Arc::new(Index { + name: "orders_customer_id_idx".to_string(), + table_name: "orders".to_string(), + columns: vec![IndexColumn { + name: "customer_id".to_string(), + order: SortOrder::Asc, + pos_in_table: 1, + }], + unique: false, + ephemeral: false, + root_page: 1, + }); + let order_id_idx = Arc::new(Index { + name: "order_items_order_id_idx".to_string(), + table_name: "order_items".to_string(), + columns: vec![IndexColumn { + name: "order_id".to_string(), + order: SortOrder::Asc, + pos_in_table: 1, + }], + unique: false, + ephemeral: false, + root_page: 1, + }); + + available_indexes + .entry("orders".to_string()) + .and_modify(|v| v.push(customer_id_idx)); + available_indexes + .entry("order_items".to_string()) + .and_modify(|v| v.push(order_id_idx)); + + // SELECT * FROM orders JOIN customers JOIN order_items + // WHERE orders.customer_id = customers.id AND orders.id = order_items.order_id AND customers.id = 42 + // expecting customers to be chosen first due to the index on customers.id and it having a selective filter (=42) + // then orders to be chosen next due to the index on orders.customer_id + // then order_items to be chosen last due to the index on order_items.order_id + let where_clause = vec![ + // orders.customer_id = customers.id + _create_binary_expr( + _create_column_expr(TABLE_NO_ORDERS, 1, false), // orders.customer_id + ast::Operator::Equals, + _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id + ), + // orders.id = order_items.order_id + _create_binary_expr( + _create_column_expr(TABLE_NO_ORDERS, 0, false), // orders.id + ast::Operator::Equals, + _create_column_expr(TABLE_NO_ORDER_ITEMS, 1, false), // order_items.order_id + ), + // customers.id = 42 + _create_binary_expr( + _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id + ast::Operator::Equals, + _create_numeric_literal("42"), + ), + ]; + + let mut access_methods_cache = HashMap::new(); + + let result = compute_best_join_order( + &table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + + // Customers (due to =42 filter) -> Orders (due to index on customer_id) -> Order_items (due to index on order_id) + assert_eq!( + best_plan.table_numbers, + vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] + ); + + assert!(matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + constraints, + } + if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "sqlite_autoindex_customers_1") + )); + + assert!(matches!( + &access_methods_cache[&best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + constraints, + } + if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "orders_customer_id_idx") + )); + + assert!(matches!( + &access_methods_cache[&best_plan.best_access_methods[2]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + constraints, + } + if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "order_items_order_id_idx") + )); + } + + struct TestColumn { + name: String, + ty: Type, + is_rowid_alias: bool, + } + + impl Default for TestColumn { + fn default() -> Self { + Self { + name: "a".to_string(), + ty: Type::Integer, + is_rowid_alias: false, + } + } + } + + #[test] + /// Test that [compute_best_join_order] faces a query with no indexes, + /// it chooses the outer table based on the most restrictive filter, + /// and builds an ephemeral index on the inner table. + fn test_join_order_no_indexes_inner_ephemeral() { + let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); + let t2 = _create_btree_table("t2", _create_column_list(&["id", "foo"], Type::Integer)); + + let mut table_references = vec![ + _create_table_reference(t1.clone(), None), + _create_table_reference( + t2.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + // SELECT * FROM t1 JOIN t2 ON t1.id = t2.id WHERE t2.foo > 10 + let where_clause = vec![ + // t2.foo > 10 + // this should make the optimizer choose t2 as the outer table despite being inner in the query, + // because it restricts the output of t2 to a smaller set of rows, resulting in a cheaper plan. + _create_binary_expr( + _create_column_expr(1, 1, false), // table 1, column 1 (foo) + ast::Operator::Greater, + _create_numeric_literal("10"), + ), + // t1.id = t2.id + // this should make the optimizer choose to create an ephemeral index on t1 + // because it is cheaper than a table scan, despite the cost of building the index. + _create_binary_expr( + _create_column_expr(0, 0, false), // table 0, column 0 (id) + ast::Operator::Equals, + _create_column_expr(1, 0, false), // table 1, column 0 (id) + ), + ]; + + let available_indexes = HashMap::new(); + let mut access_methods_cache = HashMap::new(); + + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &mut table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap() + .unwrap(); + + // Verify that t2 is chosen first due to its filter + assert_eq!(best_plan.table_numbers[0], 1, "best_plan: {:?}", best_plan); + // Verify table scan is used since there are no indexes + assert!(matches!( + access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if iter_dir == IterationDirection::Forwards + )); + // Verify that an ephemeral index was built on t1 + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + search: Search::Seek { index, .. }, + .. + } + if index.as_ref().map_or(false, |i| i.ephemeral) + ), + "expected ephemeral index, got {:?}", + access_methods_cache[&best_plan.best_access_methods[1]].kind + ); + } + + #[test] + fn test_join_order_three_tables_no_indexes() { + let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); + let t2 = _create_btree_table("t2", _create_column_list(&["id", "foo"], Type::Integer)); + let t3 = _create_btree_table("t3", _create_column_list(&["id", "foo"], Type::Integer)); + + let mut table_references = vec![ + _create_table_reference(t1.clone(), None), + _create_table_reference( + t2.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + _create_table_reference( + t3.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + let where_clause = vec![ + // t2.foo = 42 (equality filter, more selective) + _create_binary_expr( + _create_column_expr(1, 1, false), // table 1, column 1 (foo) + ast::Operator::Equals, + _create_numeric_literal("42"), + ), + // t1.foo > 10 (inequality filter, less selective) + _create_binary_expr( + _create_column_expr(0, 1, false), // table 0, column 1 (foo) + ast::Operator::Greater, + _create_numeric_literal("10"), + ), + ]; + + let available_indexes = HashMap::new(); + let mut access_methods_cache = HashMap::new(); + + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &mut table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap() + .unwrap(); + + // Verify that t2 is chosen first due to its equality filter + assert_eq!(best_plan.table_numbers[0], 1); + // Verify table scan is used since there are no indexes + assert!(matches!( + access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if iter_dir == IterationDirection::Forwards + )); + // Verify that t1 is chosen next due to its inequality filter + assert!(matches!( + access_methods_cache[&best_plan.best_access_methods[1]].kind, + AccessMethodKind::TableScan { iter_dir } + if iter_dir == IterationDirection::Forwards + )); + // Verify that t3 is chosen last due to no filters + assert!(matches!( + access_methods_cache[&best_plan.best_access_methods[2]].kind, + AccessMethodKind::TableScan { iter_dir } + if iter_dir == IterationDirection::Forwards + )); + } + + #[test] + /// Test that [compute_best_join_order] chooses a "fact table" as the outer table, + /// when it has a foreign key to all dimension tables. + fn test_compute_best_join_order_star_schema() { + const NUM_DIM_TABLES: usize = 9; + const FACT_TABLE_IDX: usize = 9; + + // Create fact table with foreign keys to all dimension tables + let mut fact_columns = vec![_create_column_rowid_alias("id")]; + for i in 0..NUM_DIM_TABLES { + fact_columns.push(_create_column_of_type( + &format!("dim{}_id", i), + Type::Integer, + )); + } + let fact_table = _create_btree_table("fact", fact_columns); + + // Create dimension tables, each with an id and value column + let dim_tables: Vec<_> = (0..NUM_DIM_TABLES) + .map(|i| { + _create_btree_table( + &format!("dim{}", i), + vec![ + _create_column_rowid_alias("id"), + _create_column_of_type("value", Type::Integer), + ], + ) + }) + .collect(); + + let mut where_clause = vec![]; + + // Add join conditions between fact and each dimension table + for i in 0..NUM_DIM_TABLES { + where_clause.push(_create_binary_expr( + _create_column_expr(FACT_TABLE_IDX, i + 1, false), // fact.dimX_id + ast::Operator::Equals, + _create_column_expr(i, 0, true), // dimX.id + )); + } + + let mut table_references = { + let mut refs = vec![_create_table_reference(dim_tables[0].clone(), None)]; + refs.extend(dim_tables.iter().skip(1).map(|t| { + _create_table_reference( + t.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ) + })); + refs.push(_create_table_reference( + fact_table.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + )); + refs + }; + + let mut access_methods_cache = HashMap::new(); + + let result = compute_best_join_order( + &table_references, + &HashMap::new(), + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + + // Expected optimal order: fact table as outer, with rowid seeks in any order on each dimension table + // Verify fact table is selected as the outer table as all the other tables can use SeekRowid + assert_eq!( + best_plan.table_numbers[0], FACT_TABLE_IDX, + "First table should be fact (table {}) due to available index, got table {} instead", + FACT_TABLE_IDX, best_plan.table_numbers[0] + ); + + // Verify access methods + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "First table (fact) should use table scan due to column filter" + ); + + for i in 1..best_plan.table_numbers.len() { + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[i]].kind, + AccessMethodKind::Search { + search: Search::RowidEq { .. }, + .. + } + ), + "Table {} should use RowidEq access method, got {:?}", + i + 1, + &access_methods_cache[&best_plan.best_access_methods[i]].kind + ); + } + } + + #[test] + /// Test that [compute_best_join_order] figures out that the tables form a "linked list" pattern + /// where a column in each table points to an indexed column in the next table, + /// and chooses the best order based on that. + fn test_compute_best_join_order_linked_list() { + const NUM_TABLES: usize = 5; + + // Create tables t1 -> t2 -> t3 -> t4 -> t5 where there is a foreign key from each table to the next + let mut tables = Vec::with_capacity(NUM_TABLES); + for i in 0..NUM_TABLES { + let mut columns = vec![_create_column_rowid_alias("id")]; + if i < NUM_TABLES - 1 { + columns.push(_create_column_of_type(&format!("next_id"), Type::Integer)); + } + tables.push(_create_btree_table(&format!("t{}", i + 1), columns)); + } + + let available_indexes = HashMap::new(); + + // Create table references + let table_references: Vec<_> = tables + .iter() + .map(|t| _create_table_reference(t.clone(), None)) + .collect(); + + // Create where clause linking each table to the next + let mut where_clause = Vec::new(); + for i in 0..NUM_TABLES - 1 { + where_clause.push(_create_binary_expr( + _create_column_expr(i, 1, false), // ti.next_id + ast::Operator::Equals, + _create_column_expr(i + 1, 0, true), // t(i+1).id + )); + } + + let mut access_methods_cache = HashMap::new(); + + // Run the optimizer + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &table_references, + &available_indexes, + &where_clause, + None, + &mut access_methods_cache, + ) + .unwrap() + .unwrap(); + + // Verify the join order is exactly t1 -> t2 -> t3 -> t4 -> t5 + for i in 0..NUM_TABLES { + assert_eq!( + best_plan.table_numbers[i], i, + "Expected table {} at position {}, got table {} instead", + i, i, best_plan.table_numbers[i] + ); + } + + // Verify access methods: + // - First table should use Table scan + assert!( + matches!( + &access_methods_cache[&best_plan.best_access_methods[0]].kind, + AccessMethodKind::TableScan { iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "First table should use Table scan" + ); + + // all of the rest should use rowid equality + for i in 1..NUM_TABLES { + let method = &access_methods_cache[&best_plan.best_access_methods[i]].kind; + assert!( + matches!( + method, + AccessMethodKind::Search { + search: Search::RowidEq { .. }, + .. + } + ), + "Table {} should use RowidEq access method, got {:?}", + i + 1, + method + ); + } + } + + fn _create_column(c: &TestColumn) -> Column { + Column { + name: Some(c.name.clone()), + ty: c.ty, + ty_str: c.ty.to_string(), + is_rowid_alias: c.is_rowid_alias, + primary_key: false, + notnull: false, + default: None, + } + } + fn _create_column_of_type(name: &str, ty: Type) -> Column { + _create_column(&TestColumn { + name: name.to_string(), + ty, + is_rowid_alias: false, + }) + } + + fn _create_column_list(names: &[&str], ty: Type) -> Vec { + names + .iter() + .map(|name| _create_column_of_type(name, ty)) + .collect() + } + + fn _create_column_rowid_alias(name: &str) -> Column { + _create_column(&TestColumn { + name: name.to_string(), + ty: Type::Integer, + is_rowid_alias: true, + }) + } + + /// Creates a BTreeTable with the given name and columns + fn _create_btree_table(name: &str, columns: Vec) -> Rc { + Rc::new(BTreeTable { + root_page: 1, // Page number doesn't matter for tests + name: name.to_string(), + primary_key_columns: vec![], + columns, + has_rowid: true, + is_strict: false, + }) + } + + /// Creates a TableReference for a BTreeTable + fn _create_table_reference( + table: Rc, + join_info: Option, + ) -> TableReference { + let name = table.name.clone(); + TableReference { + table: Table::BTree(table), + op: Operation::Scan { + iter_dir: IterationDirection::Forwards, + index: None, + }, + identifier: name, + join_info, + col_used_mask: ColumnUsedMask::new(), + } + } + + /// Creates a column expression + fn _create_column_expr(table: usize, column: usize, is_rowid_alias: bool) -> Expr { + Expr::Column { + database: None, + table, + column, + is_rowid_alias, + } + } + + /// Creates a binary expression for a WHERE clause + fn _create_binary_expr(lhs: Expr, op: Operator, rhs: Expr) -> WhereTerm { + WhereTerm { + expr: Expr::Binary(Box::new(lhs), op, Box::new(rhs)), + from_outer_join: None, + } + } + + /// Creates a numeric literal expression + fn _create_numeric_literal(value: &str) -> Expr { + Expr::Literal(ast::Literal::Numeric(value.to_string())) + } +} diff --git a/core/translate/planner.rs b/core/translate/planner.rs index 1d4cd568e..e47bb1a49 100644 --- a/core/translate/planner.rs +++ b/core/translate/planner.rs @@ -581,7 +581,7 @@ pub fn determine_where_to_eval_term( join_order .iter() .position(|t| t.table_no == table_no) - .unwrap(), + .unwrap_or(usize::MAX), )); } @@ -602,7 +602,7 @@ pub fn determine_where_to_eval_expr<'a>( let join_idx = join_order .iter() .position(|t| t.table_no == *table) - .unwrap(); + .unwrap_or(usize::MAX); eval_at = eval_at.max(EvalAt::Loop(join_idx)); } ast::Expr::Id(_) => { diff --git a/testing/orderby.test b/testing/orderby.test index b5b56cdd4..a33b32eb0 100755 --- a/testing/orderby.test +++ b/testing/orderby.test @@ -142,11 +142,11 @@ do_execsql_test case-insensitive-alias { select u.first_name as fF, count(1) > 0 as cC from users u where fF = 'Jamie' group by fF order by cC; } {Jamie|1} -do_execsql_test age_idx_order_desc { - select first_name from users order by age desc limit 3; -} {Robert -Sydney -Matthew} +#do_execsql_test age_idx_order_desc { +# select first_name from users order by age desc limit 3; +#} {Robert +#Sydney +#Matthew} do_execsql_test rowid_or_integer_pk_desc { select first_name from users order by id desc limit 3; @@ -163,40 +163,40 @@ do_execsql_test orderby_desc_verify_rows { select count(1) from (select * from users order by age desc) } {10000} -do_execsql_test orderby_desc_with_offset { - select first_name, age from users order by age desc limit 3 offset 666; -} {Francis|94 -Matthew|94 -Theresa|94} +#do_execsql_test orderby_desc_with_offset { +# select first_name, age from users order by age desc limit 3 offset 666; +#} {Francis|94 +#Matthew|94 +#Theresa|94} -do_execsql_test orderby_desc_with_filter { - select first_name, age from users where age <= 50 order by age desc limit 5; -} {Gerald|50 -Nicole|50 -Tammy|50 -Marissa|50 -Daniel|50} +#do_execsql_test orderby_desc_with_filter { +# select first_name, age from users where age <= 50 order by age desc limit 5; +#} {Gerald|50 +#Nicole|50 +#Tammy|50 +#Marissa|50 +#Daniel|50} -do_execsql_test orderby_asc_with_filter_range { - select first_name, age from users where age <= 50 and age >= 49 order by age asc limit 5; -} {William|49 -Jennifer|49 -Robert|49 -David|49 -Stephanie|49} +#do_execsql_test orderby_asc_with_filter_range { +# select first_name, age from users where age <= 50 and age >= 49 order by age asc limit 5; +#} {William|49 +#Jennifer|49 +#Robert|49 +#David|49 +#Stephanie|49} -do_execsql_test orderby_desc_with_filter_id_lt { - select id from users where id < 6666 order by id desc limit 5; -} {6665 -6664 -6663 -6662 -6661} +#do_execsql_test orderby_desc_with_filter_id_lt { +# select id from users where id < 6666 order by id desc limit 5; +#} {6665 +#6664 +#6663 +#6662 +#6661} -do_execsql_test orderby_desc_with_filter_id_le { - select id from users where id <= 6666 order by id desc limit 5; -} {6666 -6665 -6664 -6663 -6662} \ No newline at end of file +#do_execsql_test orderby_desc_with_filter_id_le { +# select id from users where id <= 6666 order by id desc limit 5; +#} {6666 +#6665 +#6664 +#6663 +#6662} \ No newline at end of file From c02d3f8bcdb1bd90593a0a214604fc91bd179d07 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 3 May 2025 13:01:25 +0300 Subject: [PATCH 04/42] Do groupby/orderby sort elimination based on optimizer decision --- core/translate/optimizer.rs | 410 +++++++++++++++++------------------- core/translate/plan.rs | 3 +- testing/groupby.test | 6 + testing/orderby.test | 76 +++---- 4 files changed, 243 insertions(+), 252 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 9e0a019f3..701b865a8 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -4,7 +4,7 @@ use limbo_sqlite3_parser::ast::{self, Expr, SortOrder}; use crate::{ parameters::PARAM_PREFIX, - schema::{BTreeTable, Column, Index, IndexColumn, Schema, Type}, + schema::{Index, IndexColumn, Schema, Table}, translate::plan::TerminationKey, types::SeekOp, util::exprs_are_equivalent, @@ -14,8 +14,8 @@ use crate::{ use super::{ emitter::Resolver, plan::{ - DeletePlan, EvalAt, GroupBy, IterationDirection, JoinInfo, JoinOrderMember, Operation, - Plan, Search, SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, + DeletePlan, EvalAt, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, + SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, }, planner::determine_where_to_eval_expr, }; @@ -55,8 +55,6 @@ fn optimize_select_plan(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { plan.join_order = best_join_order; } - eliminate_orderby_like_groupby(plan)?; - Ok(()) } @@ -108,164 +106,6 @@ fn optimize_subqueries(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { Ok(()) } -fn eliminate_orderby_like_groupby(plan: &mut SelectPlan) -> Result<()> { - if plan.order_by.is_none() | plan.group_by.is_none() { - return Ok(()); - } - if plan.table_references.len() == 0 { - return Ok(()); - } - - let order_by_clauses = plan.order_by.as_mut().unwrap(); - // TODO: let's make the group by sorter aware of the order by orders so we dont need to skip - // descending terms. - if order_by_clauses - .iter() - .any(|(_, dir)| matches!(dir, SortOrder::Desc)) - { - return Ok(()); - } - let group_by_clauses = plan.group_by.as_mut().unwrap(); - // all order by terms must be in the group by clause for order by to be eliminated - if !order_by_clauses.iter().all(|(o_expr, _)| { - group_by_clauses - .exprs - .iter() - .any(|g_expr| exprs_are_equivalent(g_expr, o_expr)) - }) { - return Ok(()); - } - - // reorder group by terms so that they match the order by terms - // this way the group by sorter will effectively do the order by sorter's job and - // we can remove the order by clause - group_by_clauses.exprs.sort_by_key(|g_expr| { - order_by_clauses - .iter() - .position(|(o_expr, _)| exprs_are_equivalent(o_expr, g_expr)) - .unwrap_or(usize::MAX) - }); - - plan.order_by = None; - Ok(()) -} - -/// Eliminate unnecessary ORDER BY clauses. -/// Returns true if the ORDER BY clause was eliminated. -fn eliminate_unnecessary_orderby( - table_references: &mut [TableReference], - available_indexes: &HashMap>>, - order_by: &mut Option>, - group_by: &Option, -) -> Result { - let Some(order) = order_by else { - return Ok(false); - }; - let Some(first_table_reference) = table_references.first_mut() else { - return Ok(false); - }; - let Some(btree_table) = first_table_reference.btree() else { - return Ok(false); - }; - // If GROUP BY clause is present, we can't rely on already ordered columns because GROUP BY reorders the data - // This early return prevents the elimination of ORDER BY when GROUP BY exists, as sorting must be applied after grouping - // And if ORDER BY clause duplicates GROUP BY we handle it later in fn eliminate_orderby_like_groupby - if group_by.is_some() { - return Ok(false); - } - let Operation::Scan { - index, iter_dir, .. - } = &mut first_table_reference.op - else { - return Ok(false); - }; - - assert!( - index.is_none(), - "Nothing shouldve transformed the scan to use an index yet" - ); - - // Special case: if ordering by just the rowid, we can remove the ORDER BY clause - if order.len() == 1 && order[0].0.is_rowid_alias_of(0) { - *iter_dir = match order[0].1 { - SortOrder::Asc => IterationDirection::Forwards, - SortOrder::Desc => IterationDirection::Backwards, - }; - *order_by = None; - return Ok(true); - } - - // Find the best matching index for the ORDER BY columns - let table_name = &btree_table.name; - let mut best_index = (None, 0); - - for (_, indexes) in available_indexes.iter() { - for index_candidate in indexes.iter().filter(|i| &i.table_name == table_name) { - let matching_columns = index_candidate.columns.iter().enumerate().take_while(|(i, c)| { - if let Some((Expr::Column { table, column, .. }, _)) = order.get(*i) { - let col_idx_in_table = btree_table - .columns - .iter() - .position(|tc| tc.name.as_ref() == Some(&c.name)); - matches!(col_idx_in_table, Some(col_idx) if *table == 0 && *column == col_idx) - } else { - false - } - }).count(); - - if matching_columns > best_index.1 { - best_index = (Some(index_candidate), matching_columns); - } - } - } - - let Some(matching_index) = best_index.0 else { - return Ok(false); - }; - let match_count = best_index.1; - - // If we found a matching index, use it for scanning - *index = Some(matching_index.clone()); - // If the order by order matches the index order, we can iterate the index in forwards order. - // If they don't, we must iterate the index in backwards order. - let index_order = &matching_index.columns.first().as_ref().unwrap().order; - *iter_dir = match (index_order, order[0].1) { - (SortOrder::Asc, SortOrder::Asc) | (SortOrder::Desc, SortOrder::Desc) => { - IterationDirection::Forwards - } - (SortOrder::Asc, SortOrder::Desc) | (SortOrder::Desc, SortOrder::Asc) => { - IterationDirection::Backwards - } - }; - - // If the index covers all ORDER BY columns, and one of the following applies: - // - the ORDER BY orders exactly match the index orderings, - // - the ORDER by orders are the exact opposite of the index orderings, - // we can remove the ORDER BY clause. - if match_count == order.len() { - let full_match = { - let mut all_match_forward = true; - let mut all_match_reverse = true; - for (i, (_, order)) in order.iter().enumerate() { - match (&matching_index.columns[i].order, order) { - (SortOrder::Asc, SortOrder::Asc) | (SortOrder::Desc, SortOrder::Desc) => { - all_match_reverse = false; - } - (SortOrder::Asc, SortOrder::Desc) | (SortOrder::Desc, SortOrder::Asc) => { - all_match_forward = false; - } - } - } - all_match_forward || all_match_reverse - }; - if full_match { - *order_by = None; - } - } - - Ok(order_by.is_none()) -} - /// Represents an n-ary join, anywhere from 1 table to N tables. #[derive(Debug, Clone)] struct JoinN { @@ -363,7 +203,30 @@ fn join_lhs_tables_to_rhs_table( build_cost: Cost(0.0), }, kind: AccessMethodKind::TableScan { - iter_dir: IterationDirection::Forwards, + iter_dir: if let Some(order_target) = maybe_order_target { + // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards + let rowid_alias_column_no = rhs_table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + + let should_use_backwards = + if let Some(rowid_alias_column_no) = rowid_alias_column_no { + order_target.0.len() == 1 + && order_target.0[0].table_no == rhs_table_number + && order_target.0[0].column_no == rowid_alias_column_no + && order_target.0[0].order == SortOrder::Desc + } else { + false + }; + if should_use_backwards { + IterationDirection::Backwards + } else { + IterationDirection::Forwards + } + } else { + IterationDirection::Forwards + }, }, }; @@ -414,7 +277,7 @@ fn join_lhs_tables_to_rhs_table( } else { AccessMethodKind::IndexScan { index: index_search.index.expect("index must exist"), - iter_dir: IterationDirection::Forwards, + iter_dir: index_search.iter_dir, } }, }; @@ -451,7 +314,7 @@ fn join_lhs_tables_to_rhs_table( let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); let cost = lhs_cost + best_access_method.cost.total(); - let mut new_numbers = lhs.map_or(vec![rhs_table_number], |l| { + let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { let mut numbers = l.table_numbers.clone(); numbers.push(rhs_table_number); numbers @@ -564,7 +427,6 @@ fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> } /// Check if the plan's row iteration order matches the [OrderTarget]'s column order -/// TODO this needs to take iteration order into account, foo vitun bar saatana fn plan_satisfies_order_target( plan: &JoinN, table_references: &[TableReference], @@ -624,11 +486,17 @@ fn plan_satisfies_order_target( } } else { // same as table scan + let iter_dir = seek_def.iter_dir; for i in 0..table_ref.table.columns().len() { let target_col = &order_target.0[target_col_idx]; + let order_matches = if iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; if target_col.table_no != *table_no || target_col.column_no != i - || target_col.order != SortOrder::Asc + || !order_matches { return false; } @@ -664,23 +532,29 @@ fn plan_satisfies_order_target( } } AccessMethodKind::TableScan { iter_dir } => { - for i in 0..table_ref.table.columns().len() { - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order != SortOrder::Asc - }; - if target_col.table_no != *table_no - || target_col.column_no != i - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; } } } @@ -688,15 +562,24 @@ fn plan_satisfies_order_target( false } +/// The result of [compute_best_join_order]. +#[derive(Debug)] +struct BestJoinOrderResult { + /// The best plan overall. + best_plan: JoinN, + /// The best plan for the given order target, if it isn't the overall best. + best_ordered_plan: Option, +} + /// Compute the best way to join a given set of tables. /// Returns the best [JoinN] if one exists, otherwise returns None. fn compute_best_join_order( table_references: &[TableReference], available_indexes: &HashMap>>, where_clause: &Vec, - maybe_order_target: Option, + maybe_order_target: Option<&OrderTarget>, access_methods_cache: &mut HashMap, -) -> Result> { +) -> Result> { if table_references.is_empty() { return Ok(None); } @@ -709,12 +592,9 @@ fn compute_best_join_order( table_references, available_indexes, where_clause, - maybe_order_target.as_ref(), + maybe_order_target, access_methods_cache, )?; - if table_references.len() == 1 { - return Ok(Some(naive_plan)); - } let mut best_ordered_plan: Option = None; let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { plan_satisfies_order_target( @@ -726,6 +606,12 @@ fn compute_best_join_order( } else { false }; + if table_references.len() == 1 { + return Ok(Some(BestJoinOrderResult { + best_plan: naive_plan, + best_ordered_plan: None, + })); + } let mut best_plan = naive_plan; let mut join_order = Vec::with_capacity(n); join_order.push(JoinOrderMember { @@ -763,7 +649,7 @@ fn compute_best_join_order( where_clause, indexes_ref, &join_order, - maybe_order_target.as_ref(), + maybe_order_target, access_methods_cache, )?; best_plan_memo.insert(mask, rel); @@ -867,7 +753,7 @@ fn compute_best_join_order( where_clause, indexes_ref, &join_order, - maybe_order_target.as_ref(), + maybe_order_target, access_methods_cache, )?; join_order.clear(); @@ -927,7 +813,14 @@ fn compute_best_join_order( } } - Ok(Some(best_plan)) + Ok(Some(BestJoinOrderResult { + best_plan, + best_ordered_plan: if best_plan_is_also_ordered { + None + } else { + best_ordered_plan + }, + })) } /// Specialized version of [compute_best_join_order] that just joins tables in the order they are given @@ -990,17 +883,27 @@ fn compute_naive_left_deep_plan( Ok(best_plan) } +#[derive(Debug, PartialEq, Clone)] struct ColumnOrder { table_no: usize, column_no: usize, order: SortOrder, } -struct OrderTarget(Vec); +#[derive(Debug, PartialEq, Clone)] +enum EliminatesSort { + GroupBy, + OrderBy, + GroupByAndOrderBy, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct OrderTarget(Vec, EliminatesSort); impl OrderTarget { fn maybe_from_iterator<'a>( list: impl Iterator + Clone, + eliminates_sort: EliminatesSort, ) -> Option { if list.clone().count() == 0 { return None; @@ -1023,6 +926,7 @@ impl OrderTarget { } }) .collect(), + eliminates_sort, )) } } @@ -1031,6 +935,9 @@ impl OrderTarget { /// Ideally, a join order is both efficient in joining the tables /// but also returns the results in an order that minimizes the amount of /// sorting that needs to be done later (either in GROUP BY, ORDER BY, or both). +/// +/// TODO: this does not currently handle the case where we definitely cannot eliminate +/// the ORDER BY sorter, but we could still eliminate the GROUP BY sorter. fn compute_order_target( order_by: &Option>, group_by: Option<&mut GroupBy>, @@ -1039,12 +946,14 @@ fn compute_order_target( // No ordering demands - we don't care what order the joined result rows are in (None, None) => None, // Only ORDER BY - we would like the joined result rows to be in the order specified by the ORDER BY - (Some(order_by), None) => { - OrderTarget::maybe_from_iterator(order_by.iter().map(|(expr, order)| (expr, *order))) - } + (Some(order_by), None) => OrderTarget::maybe_from_iterator( + order_by.iter().map(|(expr, order)| (expr, *order)), + EliminatesSort::OrderBy, + ), // Only GROUP BY - we would like the joined result rows to be in the order specified by the GROUP BY (None, Some(group_by)) => OrderTarget::maybe_from_iterator( group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + EliminatesSort::GroupBy, ), // Both ORDER BY and GROUP BY: // If the GROUP BY does not contain all the expressions in the ORDER BY, @@ -1066,6 +975,7 @@ fn compute_order_target( if !group_by_contains_all { return OrderTarget::maybe_from_iterator( group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + EliminatesSort::GroupBy, ); } // If yes, let's try to target an ordering that matches the GROUP BY columns, @@ -1088,6 +998,7 @@ fn compute_order_target( .chain(std::iter::repeat(&SortOrder::Asc)), ) .map(|(expr, dir)| (expr, *dir)), + EliminatesSort::GroupByAndOrderBy, ) } } @@ -1102,25 +1013,68 @@ fn use_indexes( ) -> Result>> { let mut access_methods_cache = HashMap::new(); let maybe_order_target = compute_order_target(order_by, group_by.as_mut()); - let Some(best) = compute_best_join_order( + let Some(best_join_order_result) = compute_best_join_order( table_references, available_indexes, where_clause, - maybe_order_target, + maybe_order_target.as_ref(), &mut access_methods_cache, )? else { return Ok(None); }; + let BestJoinOrderResult { + best_plan, + best_ordered_plan, + } = best_join_order_result; + + let best_plan = if let Some(best_ordered_plan) = best_ordered_plan { + let best_unordered_plan_cost = best_plan.cost; + let best_ordered_plan_cost = best_ordered_plan.cost; + const SORT_COST_PER_ROW_MULTIPLIER: f64 = 0.001; + let sorting_penalty = + Cost(best_plan.output_cardinality as f64 * SORT_COST_PER_ROW_MULTIPLIER); + if best_unordered_plan_cost + sorting_penalty > best_ordered_plan_cost { + best_ordered_plan + } else { + best_plan + } + } else { + best_plan + }; + + if let Some(order_target) = maybe_order_target { + let satisfies_order_target = plan_satisfies_order_target( + &best_plan, + table_references, + &mut access_methods_cache, + &order_target, + ); + if satisfies_order_target { + match order_target.1 { + EliminatesSort::GroupBy => { + let _ = group_by.as_mut().and_then(|g| g.sort_order.take()); + } + EliminatesSort::OrderBy => { + let _ = order_by.take(); + } + EliminatesSort::GroupByAndOrderBy => { + let _ = group_by.as_mut().and_then(|g| g.sort_order.take()); + let _ = order_by.take(); + } + } + } + } + let (mut best_access_methods, best_table_numbers) = { - let mut kinds = Vec::with_capacity(best.best_access_methods.len()); - for am_idx in best.best_access_methods.iter() { + let mut kinds = Vec::with_capacity(best_plan.best_access_methods.len()); + for am_idx in best_plan.best_access_methods.iter() { // take value from cache let am = access_methods_cache.remove(am_idx).unwrap(); kinds.push(am.kind); } - (kinds, best.table_numbers) + (kinds, best_plan.table_numbers) }; let mut to_remove_from_where_clause = vec![]; for table_number in best_table_numbers.iter().rev() { @@ -1132,7 +1086,9 @@ fn use_indexes( // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. assert!( matches!(access_method, AccessMethodKind::TableScan { .. }), - "nothing in the current optimizer should be able to optimize subqueries" + "nothing in the current optimizer should be able to optimize subqueries, but got {:?} for table {}", + access_method, + table_references[*table_number].table.get_name() ); continue; } @@ -1803,7 +1759,32 @@ pub fn try_extract_index_search_from_where_clause( false, input_cardinality, ); - if cost.total() < best_index.cost.total() { + let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { + let mut all_same_direction = true; + let mut all_opposite_direction = true; + for i in 0..order_target.0.len().min(index.columns.len()) { + if order_target.0[i].table_no != table_index + || order_target.0[i].column_no != index.columns[i].pos_in_table + { + all_same_direction = false; + all_opposite_direction = false; + break; + } + if order_target.0[i].order == index.columns[i].order { + all_opposite_direction = false; + } else { + all_same_direction = false; + } + } + if all_same_direction || all_opposite_direction { + Cost(1.0) + } else { + Cost(0.0) + } + } else { + Cost(0.0) + }; + if cost.total() < best_index.cost.total() + order_satisfiability_bonus { best_index.index = Some(Arc::clone(index)); best_index.cost = cost; best_index.constraints.clear(); @@ -1813,7 +1794,7 @@ pub fn try_extract_index_search_from_where_clause( // We haven't found a persistent btree index that is any better than a full table scan; // let's see if building an ephemeral index would be better. - if best_index.index.is_none() { + if best_index.index.is_none() && matches!(table_reference.table, Table::BTree(_)) { let (ephemeral_cost, constraints_with_col_idx, mut constraints_without_col_idx) = ephemeral_index_estimate_cost( where_clause, @@ -1841,11 +1822,7 @@ pub fn try_extract_index_search_from_where_clause( return Ok(None); } - if best_index.constraints.is_empty() { - return Ok(Some(best_index)); - } - - let iter_dir = if let Some(order_target) = maybe_order_target { + best_index.iter_dir = if let Some(order_target) = maybe_order_target { // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards let index = best_index.index.as_ref().unwrap(); let mut should_use_backwards = true; @@ -1870,9 +1847,16 @@ pub fn try_extract_index_search_from_where_clause( IterationDirection::Forwards }; + if best_index.constraints.is_empty() { + return Ok(Some(best_index)); + } + // Build the seek definition - let seek_def = - build_seek_def_from_index_constraints(&best_index.constraints, iter_dir, where_clause)?; + let seek_def = build_seek_def_from_index_constraints( + &best_index.constraints, + best_index.iter_dir, + where_clause, + )?; // Remove the used terms from the where_clause since they are now part of the seek definition // Sort terms by position in descending order to avoid shifting indices during removal diff --git a/core/translate/plan.rs b/core/translate/plan.rs index a422b6fed..50e325832 100644 --- a/core/translate/plan.rs +++ b/core/translate/plan.rs @@ -64,9 +64,10 @@ impl ResultSetColumn { #[derive(Debug, Clone)] pub struct GroupBy { pub exprs: Vec, + /// sort order, if a sorter is required (= the columns aren't already in the correct order) + pub sort_order: Option>, /// having clause split into a vec at 'AND' boundaries. pub having: Option>, - pub sort_order: Option>, } /// In a query plan, WHERE clause conditions and JOIN conditions are all folded into a vector of WhereTerm. diff --git a/testing/groupby.test b/testing/groupby.test index 9fce2e83e..70141be0a 100644 --- a/testing/groupby.test +++ b/testing/groupby.test @@ -192,3 +192,9 @@ do_execsql_test groupby_orderby_removal_regression_test { } {1|Foster|1 2|Salazar|1 3|Perry|1} + +do_execsql_test group_by_no_sorting_required { + select age, count(1) from users group by age limit 3; +} {1|112 +2|113 +3|97} \ No newline at end of file diff --git a/testing/orderby.test b/testing/orderby.test index a33b32eb0..b5b56cdd4 100755 --- a/testing/orderby.test +++ b/testing/orderby.test @@ -142,11 +142,11 @@ do_execsql_test case-insensitive-alias { select u.first_name as fF, count(1) > 0 as cC from users u where fF = 'Jamie' group by fF order by cC; } {Jamie|1} -#do_execsql_test age_idx_order_desc { -# select first_name from users order by age desc limit 3; -#} {Robert -#Sydney -#Matthew} +do_execsql_test age_idx_order_desc { + select first_name from users order by age desc limit 3; +} {Robert +Sydney +Matthew} do_execsql_test rowid_or_integer_pk_desc { select first_name from users order by id desc limit 3; @@ -163,40 +163,40 @@ do_execsql_test orderby_desc_verify_rows { select count(1) from (select * from users order by age desc) } {10000} -#do_execsql_test orderby_desc_with_offset { -# select first_name, age from users order by age desc limit 3 offset 666; -#} {Francis|94 -#Matthew|94 -#Theresa|94} +do_execsql_test orderby_desc_with_offset { + select first_name, age from users order by age desc limit 3 offset 666; +} {Francis|94 +Matthew|94 +Theresa|94} -#do_execsql_test orderby_desc_with_filter { -# select first_name, age from users where age <= 50 order by age desc limit 5; -#} {Gerald|50 -#Nicole|50 -#Tammy|50 -#Marissa|50 -#Daniel|50} +do_execsql_test orderby_desc_with_filter { + select first_name, age from users where age <= 50 order by age desc limit 5; +} {Gerald|50 +Nicole|50 +Tammy|50 +Marissa|50 +Daniel|50} -#do_execsql_test orderby_asc_with_filter_range { -# select first_name, age from users where age <= 50 and age >= 49 order by age asc limit 5; -#} {William|49 -#Jennifer|49 -#Robert|49 -#David|49 -#Stephanie|49} +do_execsql_test orderby_asc_with_filter_range { + select first_name, age from users where age <= 50 and age >= 49 order by age asc limit 5; +} {William|49 +Jennifer|49 +Robert|49 +David|49 +Stephanie|49} -#do_execsql_test orderby_desc_with_filter_id_lt { -# select id from users where id < 6666 order by id desc limit 5; -#} {6665 -#6664 -#6663 -#6662 -#6661} +do_execsql_test orderby_desc_with_filter_id_lt { + select id from users where id < 6666 order by id desc limit 5; +} {6665 +6664 +6663 +6662 +6661} -#do_execsql_test orderby_desc_with_filter_id_le { -# select id from users where id <= 6666 order by id desc limit 5; -#} {6666 -#6665 -#6664 -#6663 -#6662} \ No newline at end of file +do_execsql_test orderby_desc_with_filter_id_le { + select id from users where id <= 6666 order by id desc limit 5; +} {6666 +6665 +6664 +6663 +6662} \ No newline at end of file From 5f724d6b2e9c9976beedf98a4c6a0381d107fb74 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 3 May 2025 15:17:16 +0300 Subject: [PATCH 05/42] Add more comments to join ordering logic --- core/translate/optimizer.rs | 78 ++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 701b865a8..306975ac3 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -358,7 +358,7 @@ enum AccessMethodKind { } /// A bitmask representing which tables are in the join. -/// For example, if the bitmask is 0b1101, then the tables 0, 1, and 3 are in the join. +/// For example, if the bitmask is 0b1101, then the tables 0, 2, and 3 are in the join. /// Since this is a mask, the tables aren't ordered. /// This is used for memoizing the best way to join a subset of N tables. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -580,12 +580,12 @@ fn compute_best_join_order( maybe_order_target: Option<&OrderTarget>, access_methods_cache: &mut HashMap, ) -> Result> { + // Skip work if we have no tables to consider. if table_references.is_empty() { return Ok(None); } - let n = table_references.len(); - let mut best_plan_memo: HashMap = HashMap::new(); + let num_tables = table_references.len(); // Compute naive left-to-right plan to use as pruning threshold let naive_plan = compute_naive_left_deep_plan( @@ -595,6 +595,10 @@ fn compute_best_join_order( maybe_order_target, access_methods_cache, )?; + + // Keep track of both 1. the best plan overall (not considering sorting), and 2. the best ordered plan (which might not be the same). + // We assign Some Cost (tm) to any required sort operation, so the best ordered plan may end up being + // the one we choose, if the cost reduction from avoiding sorting brings it below the cost of the overall best one. let mut best_ordered_plan: Option = None; let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { plan_satisfies_order_target( @@ -606,6 +610,8 @@ fn compute_best_join_order( } else { false }; + + // If we have one table, then the "naive left-to-right plan" is always the best. if table_references.len() == 1 { return Ok(Some(BestJoinOrderResult { best_plan: naive_plan, @@ -613,11 +619,16 @@ fn compute_best_join_order( })); } let mut best_plan = naive_plan; - let mut join_order = Vec::with_capacity(n); + + // Reuse a single mutable join order to avoid allocating join orders per permutation. + let mut join_order = Vec::with_capacity(num_tables); join_order.push(JoinOrderMember { table_no: 0, is_outer: false, }); + + // Keep track of the current best cost so we can short-circuit planning for subplans + // that already exceed the cost of the current best plan. let cost_upper_bound = best_plan.cost; let cost_upper_bound_ordered = { if best_plan_is_also_ordered { @@ -627,8 +638,16 @@ fn compute_best_join_order( } }; - // Base cases: 1-table subsets - for i in 0..n { + // Keep track of the best plan for a given subset of tables. + // Consider this example: we have tables a,b,c,d to join. + // if we find that 'b JOIN a' is better than 'a JOIN b', then we don't need to even try + // to do 'a JOIN b JOIN c', because we know 'b JOIN a JOIN c' is going to be better. + // This is due to the commutativity and associativity of inner joins. + let mut best_plan_memo: HashMap = HashMap::new(); + + // Dynamic programming base case: calculate the best way to access each single table, as if + // there were no other tables. + for i in 0..num_tables { let mut mask = JoinBitmask::new(0); mask.set(i); let table_ref = &table_references[i]; @@ -656,6 +675,10 @@ fn compute_best_join_order( } join_order.clear(); + // As mentioned, inner joins are commutative. Outer joins are NOT. + // Example: + // "a LEFT JOIN b" can NOT be reordered as "b LEFT JOIN a". + // If there are outer joins in the plan, ensure correct ordering. let left_join_illegal_map = { let left_join_count = table_references .iter() @@ -687,27 +710,37 @@ fn compute_best_join_order( } }; - // Build larger plans - for subset_size in 2..=n { - for mask in generate_join_bitmasks(n, subset_size) { + // Now that we have our single-table base cases, we can start considering join subsets of 2 tables and more. + // Try to join each single table to each other table. + for subset_size in 2..=num_tables { + for mask in generate_join_bitmasks(num_tables, subset_size) { + // Keep track of the best way to join this subset of tables. + // Take the (a,b,c,d) example from above: + // E.g. for "a JOIN b JOIN c", the possibilities are (a,b,c), (a,c,b), (b,a,c) and so on. + // If we find out (b,a,c) is the best way to join these three, then we ONLY need to compute + // the cost of (b,a,c,d) in the final step, because (a,b,c,d) (and all others) are guaranteed to be worse. let mut best_for_mask: Option = None; + // also keep track of the best plan for this subset that orders the rows in an Interesting Way (tm), + // i.e. allows us to eliminate sort operations downstream. let (mut best_ordered_for_mask, mut best_for_mask_is_also_ordered) = (None, false); - // Try all possible RHS base tables in this mask - for rhs_idx in 0..n { + + // Try to join all subsets (masks) with all other tables. + // In this block, LHS is always (n-1) tables, and RHS is a single table. + for rhs_idx in 0..num_tables { let rhs_bit = 1 << rhs_idx; - // Make sure rhs is in this mask + // If the RHS table isn't a member of this join subset, skip. if mask.0 & rhs_bit == 0 { continue; } - // LHS = mask - rhs + // If there are no other tables except RHS, skip. let lhs_mask = JoinBitmask(mask.0 ^ rhs_bit); if lhs_mask.0 == 0 { continue; } - // Skip illegal left join ordering + // If this join ordering would violate LEFT JOIN ordering restrictions, skip. if let Some(illegal_lhs) = left_join_illegal_map .as_ref() .and_then(|deps| deps.get(&rhs_idx)) @@ -718,6 +751,8 @@ fn compute_best_join_order( } } + // If the already cached plan for this subset was too crappy to consider, + // then joining it with RHS won't help. Skip. let Some(lhs) = best_plan_memo.get(&lhs_mask) else { continue; }; @@ -728,6 +763,7 @@ fn compute_best_join_order( indexes_ref = indexes; } + // Build a JoinOrder out of the table bitmask we are now considering. for table_no in lhs.table_numbers.iter() { join_order.push(JoinOrderMember { table_no: *table_no, @@ -746,6 +782,7 @@ fn compute_best_join_order( }); assert!(join_order.len() == subset_size); + // Calculate the best way to join LHS with RHS. let rel = join_lhs_tables_to_rhs_table( Some(lhs), rhs_idx, @@ -758,6 +795,9 @@ fn compute_best_join_order( )?; join_order.clear(); + // Since cost_upper_bound_ordered is always >= to cost_upper_bound, + // if the cost we calculated for this plan is worse than cost_upper_bound_ordered, + // this join subset is already worse than our best plan for the ENTIRE query, so skip. if rel.cost >= cost_upper_bound_ordered { continue; } @@ -773,7 +813,9 @@ fn compute_best_join_order( false }; + // If this plan is worse than our overall best, it might still be the best ordered plan. if rel.cost >= cost_upper_bound { + // But if it isn't, skip. if !satisfies_order_target { continue; } @@ -792,7 +834,7 @@ fn compute_best_join_order( if let Some(rel) = best_ordered_for_mask.take() { let cost = rel.cost; - let has_all_tables = mask.0.count_ones() as usize == n; + let has_all_tables = mask.0.count_ones() as usize == num_tables; if has_all_tables && cost_upper_bound_ordered > cost { best_ordered_plan = Some(rel); } @@ -800,7 +842,7 @@ fn compute_best_join_order( if let Some(rel) = best_for_mask.take() { let cost = rel.cost; - let has_all_tables = mask.0.count_ones() as usize == n; + let has_all_tables = mask.0.count_ones() as usize == num_tables; if has_all_tables { if cost_upper_bound > cost { best_plan = rel; @@ -3049,7 +3091,7 @@ mod tests { ) .unwrap() .unwrap(); - /// Should just be a table scan access method + // Should just be a table scan access method assert!(matches!( access_methods_cache[&best_plan.best_access_methods[0]].kind, AccessMethodKind::TableScan { iter_dir } @@ -3615,7 +3657,7 @@ mod tests { )); } - let mut table_references = { + let table_references = { let mut refs = vec![_create_table_reference(dim_tables[0].clone(), None)]; refs.extend(dim_tables.iter().skip(1).map(|t| { _create_table_reference( From 77f11ba0043c9b14b306ebee634cd4f761d6a2df Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Thu, 8 May 2025 15:47:02 +0300 Subject: [PATCH 06/42] simplify AccessMethodKind --- core/translate/optimizer.rs | 131 ++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 306975ac3..5e60a5445 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -202,7 +202,8 @@ fn join_lhs_tables_to_rhs_table( ), build_cost: Cost(0.0), }, - kind: AccessMethodKind::TableScan { + kind: AccessMethodKind::Scan { + index: None, iter_dir: if let Some(order_target) = maybe_order_target { // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards let rowid_alias_column_no = rhs_table_reference @@ -269,16 +270,15 @@ fn join_lhs_tables_to_rhs_table( (None, Some(index_search)) => { best_access_method = AccessMethod { cost: index_search.cost, - kind: if index_search.search.is_some() { - AccessMethodKind::Search { - search: index_search.search.expect("search must exist"), + kind: match index_search.search { + Some(search) => AccessMethodKind::Search { + search, constraints: index_search.constraints, - } - } else { - AccessMethodKind::IndexScan { - index: index_search.index.expect("index must exist"), + }, + None => AccessMethodKind::Scan { + index: index_search.index, iter_dir: index_search.iter_dir, - } + }, }, }; } @@ -294,16 +294,15 @@ fn join_lhs_tables_to_rhs_table( } else { best_access_method = AccessMethod { cost: index_search.cost, - kind: if index_search.search.is_some() { - AccessMethodKind::Search { - search: index_search.search.expect("search must exist"), + kind: match index_search.search { + Some(search) => AccessMethodKind::Search { + search, constraints: index_search.constraints, - } - } else { - AccessMethodKind::IndexScan { - index: index_search.index.expect("index must exist"), - iter_dir: IterationDirection::Forwards, - } + }, + None => AccessMethodKind::Scan { + index: index_search.index, + iter_dir: index_search.iter_dir, + }, }, }; } @@ -344,13 +343,12 @@ struct AccessMethod { #[derive(Debug, Clone)] enum AccessMethodKind { - TableScan { - iter_dir: IterationDirection, - }, - IndexScan { - index: Arc, + /// A full scan, which can be an index scan or a table scan. + Scan { + index: Option>, iter_dir: IterationDirection, }, + /// A search, which can be an index seek or a rowid-based search. Search { search: Search, constraints: Vec, @@ -440,7 +438,39 @@ fn plan_satisfies_order_target( let access_method = &plan.best_access_methods[target_col_idx]; let access_method = access_methods_cache.get(access_method).unwrap(); match &access_method.kind { - AccessMethodKind::IndexScan { index, iter_dir } => { + AccessMethodKind::Scan { + index: None, + iter_dir, + } => { + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + AccessMethodKind::Scan { + index: Some(index), + iter_dir, + } => { // The index columns must match the order target columns for this table for index_col in index.columns.iter() { let target_col = &order_target.0[target_col_idx]; @@ -531,32 +561,6 @@ fn plan_satisfies_order_target( return true; } } - AccessMethodKind::TableScan { iter_dir } => { - let rowid_alias_col = table_ref - .table - .columns() - .iter() - .position(|c| c.is_rowid_alias); - let Some(rowid_alias_col) = rowid_alias_col else { - return false; - }; - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order == SortOrder::Desc - }; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } } } false @@ -1127,7 +1131,7 @@ fn use_indexes( ) { // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. assert!( - matches!(access_method, AccessMethodKind::TableScan { .. }), + matches!(access_method, AccessMethodKind::Scan { index: None, .. }), "nothing in the current optimizer should be able to optimize subqueries, but got {:?} for table {}", access_method, table_references[*table_number].table.get_name() @@ -1135,14 +1139,7 @@ fn use_indexes( continue; } table_references[*table_number].op = match access_method { - AccessMethodKind::TableScan { iter_dir } => Operation::Scan { - iter_dir, - index: None, - }, - AccessMethodKind::IndexScan { index, iter_dir } => Operation::Scan { - iter_dir, - index: Some(index), - }, + AccessMethodKind::Scan { iter_dir, index } => Operation::Scan { iter_dir, index }, AccessMethodKind::Search { search, constraints, @@ -3094,7 +3091,7 @@ mod tests { // Should just be a table scan access method assert!(matches!( access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); } @@ -3256,7 +3253,7 @@ mod tests { assert!( matches!( &access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), "expected TableScan access method, got {:?}", @@ -3524,7 +3521,7 @@ mod tests { // Verify table scan is used since there are no indexes assert!(matches!( access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that an ephemeral index was built on t1 @@ -3599,19 +3596,19 @@ mod tests { // Verify table scan is used since there are no indexes assert!(matches!( access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that t1 is chosen next due to its inequality filter assert!(matches!( access_methods_cache[&best_plan.best_access_methods[1]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that t3 is chosen last due to no filters assert!(matches!( access_methods_cache[&best_plan.best_access_methods[2]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); } @@ -3703,7 +3700,7 @@ mod tests { assert!( matches!( &access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), "First table (fact) should use table scan due to column filter" @@ -3787,7 +3784,7 @@ mod tests { assert!( matches!( &access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::TableScan { iter_dir } + AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), "First table should use Table scan" From 87850e5706c46ad90933268a8de1becbaee5caee Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Thu, 8 May 2025 16:55:22 +0300 Subject: [PATCH 07/42] simplify --- core/translate/optimizer.rs | 151 ++++++++++++++---------------------- 1 file changed, 60 insertions(+), 91 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 5e60a5445..3bb06129f 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -192,46 +192,7 @@ fn join_lhs_tables_to_rhs_table( * output_cardinality_multiplier) .ceil() as usize; - // Let's find the best access method and its cost. - // Initialize the best access method to a table scan. - let mut best_access_method = AccessMethod { - // worst case: read all rows of the inner table N times, where N is the number of rows in the outer best_plan - cost: ScanCost { - run_cost: estimate_page_io_cost( - input_cardinality as f64 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, - ), - build_cost: Cost(0.0), - }, - kind: AccessMethodKind::Scan { - index: None, - iter_dir: if let Some(order_target) = maybe_order_target { - // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards - let rowid_alias_column_no = rhs_table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - - let should_use_backwards = - if let Some(rowid_alias_column_no) = rowid_alias_column_no { - order_target.0.len() == 1 - && order_target.0[0].table_no == rhs_table_number - && order_target.0[0].column_no == rowid_alias_column_no - && order_target.0[0].order == SortOrder::Desc - } else { - false - }; - if should_use_backwards { - IterationDirection::Backwards - } else { - IterationDirection::Forwards - } - } else { - IterationDirection::Forwards - }, - }, - }; - - let mut rowid_search = None; + let mut rowid_candidate = None; for (wi, term) in where_clause.iter().enumerate() { if let Some(rse) = try_extract_rowid_search_expression( term, @@ -243,10 +204,10 @@ fn join_lhs_tables_to_rhs_table( maybe_order_target, input_cardinality as f64, )? { - rowid_search = Some(rse); + rowid_candidate = Some(rse); } } - let index_search = try_extract_index_search_from_where_clause( + let index_candidate = try_extract_index_search_from_where_clause( where_clause, loop_idx, rhs_table_number, @@ -257,57 +218,65 @@ fn join_lhs_tables_to_rhs_table( input_cardinality as f64, )?; - match (rowid_search, index_search) { - (Some(rowid_search), None) => { - best_access_method = AccessMethod { - cost: rowid_search.cost, - kind: AccessMethodKind::Search { - search: rowid_search.search.expect("search must exist"), - constraints: rowid_search.constraints, + let best_candidate = match (index_candidate, rowid_candidate) { + (Some(i), Some(r)) => Some(if r.cost.total() < i.cost.total() { + r + } else { + i + }), + (Some(i), None) => Some(i), + (None, r) => r, + }; + + let best_access_method = match best_candidate { + Some(c) => AccessMethod { + cost: c.cost, + kind: match c.search { + Some(search) => AccessMethodKind::Search { + search, + constraints: c.constraints, }, - }; - } - (None, Some(index_search)) => { - best_access_method = AccessMethod { - cost: index_search.cost, - kind: match index_search.search { - Some(search) => AccessMethodKind::Search { - search, - constraints: index_search.constraints, - }, - None => AccessMethodKind::Scan { - index: index_search.index, - iter_dir: index_search.iter_dir, - }, + None => AccessMethodKind::Scan { + index: c.index, + iter_dir: c.iter_dir, }, - }; - } - (Some(rowid_search), Some(index_search)) => { - if rowid_search.cost.total() < index_search.cost.total() { - best_access_method = AccessMethod { - cost: rowid_search.cost, - kind: AccessMethodKind::Search { - search: rowid_search.search.expect("search must exist"), - constraints: rowid_search.constraints, - }, - }; - } else { - best_access_method = AccessMethod { - cost: index_search.cost, - kind: match index_search.search { - Some(search) => AccessMethodKind::Search { - search, - constraints: index_search.constraints, - }, - None => AccessMethodKind::Scan { - index: index_search.index, - iter_dir: index_search.iter_dir, - }, - }, - }; - } - } - (None, None) => {} + }, + }, + None => AccessMethod { + cost: ScanCost { + run_cost: estimate_page_io_cost( + input_cardinality as f64 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, + ), + build_cost: Cost(0.0), + }, + kind: AccessMethodKind::Scan { + index: None, + iter_dir: if let Some(order_target) = maybe_order_target { + // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards + let rowid_alias_column_no = rhs_table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + + let should_use_backwards = + if let Some(rowid_alias_column_no) = rowid_alias_column_no { + order_target.0.len() == 1 + && order_target.0[0].table_no == rhs_table_number + && order_target.0[0].column_no == rowid_alias_column_no + && order_target.0[0].order == SortOrder::Desc + } else { + false + }; + if should_use_backwards { + IterationDirection::Backwards + } else { + IterationDirection::Forwards + } + } else { + IterationDirection::Forwards + }, + }, + }, }; let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); From 3b1aef4a9eabb57ac6913ef0378ea2495f671138 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Fri, 9 May 2025 15:28:39 +0300 Subject: [PATCH 08/42] Do Less Work (tm) - everything works except ephemeral --- core/translate/optimizer.rs | 1512 +++++++++++++++++------------------ core/translate/planner.rs | 189 +++++ 2 files changed, 942 insertions(+), 759 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 3bb06129f..754bdeaca 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -1,4 +1,4 @@ -use std::{cmp::Ordering, collections::HashMap, sync::Arc}; +use std::{cell::RefCell, cmp::Ordering, collections::HashMap, sync::Arc}; use limbo_sqlite3_parser::ast::{self, Expr, SortOrder}; @@ -17,7 +17,7 @@ use super::{ DeletePlan, EvalAt, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, }, - planner::determine_where_to_eval_expr, + planner::{determine_where_to_eval_expr, table_mask_from_expr, TableMask}, }; pub fn optimize_plan(plan: &mut Plan, schema: &Schema) -> Result<()> { @@ -123,15 +123,15 @@ const SELECTIVITY_EQ: f64 = 0.01; const SELECTIVITY_RANGE: f64 = 0.4; const SELECTIVITY_OTHER: f64 = 0.9; -fn join_lhs_tables_to_rhs_table( +fn join_lhs_tables_to_rhs_table<'a>( lhs: Option<&JoinN>, rhs_table_number: usize, rhs_table_reference: &TableReference, where_clause: &Vec, - indexes_for_table: &[Arc], + constraints: &'a [Constraints], join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, - access_methods_cache: &mut HashMap, + access_methods_arena: &'a RefCell>>, ) -> Result { let loop_idx = lhs.map_or(0, |l| l.table_numbers.len()); // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. @@ -192,93 +192,15 @@ fn join_lhs_tables_to_rhs_table( * output_cardinality_multiplier) .ceil() as usize; - let mut rowid_candidate = None; - for (wi, term) in where_clause.iter().enumerate() { - if let Some(rse) = try_extract_rowid_search_expression( - term, - wi, - loop_idx, - rhs_table_number, - rhs_table_reference, - &join_order, - maybe_order_target, - input_cardinality as f64, - )? { - rowid_candidate = Some(rse); - } - } - let index_candidate = try_extract_index_search_from_where_clause( - where_clause, - loop_idx, + let best_access_method = find_best_access_method_for_join_order( rhs_table_number, rhs_table_reference, - indexes_for_table, + constraints, &join_order, maybe_order_target, input_cardinality as f64, )?; - let best_candidate = match (index_candidate, rowid_candidate) { - (Some(i), Some(r)) => Some(if r.cost.total() < i.cost.total() { - r - } else { - i - }), - (Some(i), None) => Some(i), - (None, r) => r, - }; - - let best_access_method = match best_candidate { - Some(c) => AccessMethod { - cost: c.cost, - kind: match c.search { - Some(search) => AccessMethodKind::Search { - search, - constraints: c.constraints, - }, - None => AccessMethodKind::Scan { - index: c.index, - iter_dir: c.iter_dir, - }, - }, - }, - None => AccessMethod { - cost: ScanCost { - run_cost: estimate_page_io_cost( - input_cardinality as f64 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, - ), - build_cost: Cost(0.0), - }, - kind: AccessMethodKind::Scan { - index: None, - iter_dir: if let Some(order_target) = maybe_order_target { - // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards - let rowid_alias_column_no = rhs_table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - - let should_use_backwards = - if let Some(rowid_alias_column_no) = rowid_alias_column_no { - order_target.0.len() == 1 - && order_target.0[0].table_no == rhs_table_number - && order_target.0[0].column_no == rowid_alias_column_no - && order_target.0[0].order == SortOrder::Desc - } else { - false - }; - if should_use_backwards { - IterationDirection::Backwards - } else { - IterationDirection::Forwards - } - } else { - IterationDirection::Forwards - }, - }, - }, - }; - let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); let cost = lhs_cost + best_access_method.cost.total(); @@ -288,30 +210,68 @@ fn join_lhs_tables_to_rhs_table( numbers }); - access_methods_cache.insert(access_methods_cache.len(), best_access_method); - let access_method_idx = access_methods_cache.len() - 1; + access_methods_arena.borrow_mut().push(best_access_method); + let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); + best_access_methods.push(access_methods_arena.borrow().len() - 1); Ok(JoinN { table_numbers: new_numbers, - best_access_methods: lhs.map_or(vec![access_method_idx], |l| { - let mut methods = l.best_access_methods.clone(); - methods.push(access_method_idx); - methods - }), + best_access_methods, output_cardinality, cost, }) } #[derive(Debug, Clone)] -struct AccessMethod { +pub struct AccessMethod<'a> { // The estimated number of page fetches. // We are ignoring CPU cost for now. pub cost: ScanCost, - pub kind: AccessMethodKind, + pub kind: AccessMethodKind<'a>, +} + +impl<'a> AccessMethod<'a> { + pub fn set_iter_dir(&mut self, new_dir: IterationDirection) { + match &mut self.kind { + AccessMethodKind::Scan { iter_dir, .. } => *iter_dir = new_dir, + AccessMethodKind::Search { iter_dir, .. } => *iter_dir = new_dir, + } + } + + pub fn set_constraints(&mut self, index: Option>, constraints: &'a [Constraint]) { + match (&mut self.kind, constraints.is_empty()) { + ( + AccessMethodKind::Search { + constraints, + index: i, + .. + }, + false, + ) => { + *constraints = constraints; + *i = index; + } + (AccessMethodKind::Search { iter_dir, .. }, true) => { + self.kind = AccessMethodKind::Scan { + index, + iter_dir: *iter_dir, + }; + } + (AccessMethodKind::Scan { iter_dir, .. }, false) => { + self.kind = AccessMethodKind::Search { + index, + iter_dir: *iter_dir, + constraints, + }; + } + (AccessMethodKind::Scan { index: i, .. }, true) => { + *i = index; + } + } + } } #[derive(Debug, Clone)] -enum AccessMethodKind { +pub enum AccessMethodKind<'a> { /// A full scan, which can be an index scan or a table scan. Scan { index: Option>, @@ -319,33 +279,12 @@ enum AccessMethodKind { }, /// A search, which can be an index seek or a rowid-based search. Search { - search: Search, - constraints: Vec, + index: Option>, + iter_dir: IterationDirection, + constraints: &'a [Constraint], }, } -/// A bitmask representing which tables are in the join. -/// For example, if the bitmask is 0b1101, then the tables 0, 2, and 3 are in the join. -/// Since this is a mask, the tables aren't ordered. -/// This is used for memoizing the best way to join a subset of N tables. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[repr(transparent)] -struct JoinBitmask(u128); - -impl JoinBitmask { - fn new(init: u128) -> Self { - Self(init) - } - - fn set(&mut self, i: usize) { - self.0 |= 1 << i; - } - - fn intersects(&self, other: &Self) -> bool { - self.0 & other.0 != 0 - } -} - /// Iterator that generates all possible size k bitmasks for a given number of tables. /// For example, given: 3 tables and k=2, the bitmasks are: /// - 0b011 (tables 0, 1) @@ -368,14 +307,14 @@ impl JoinBitmaskIter { } impl Iterator for JoinBitmaskIter { - type Item = JoinBitmask; + type Item = TableMask; fn next(&mut self) -> Option { if self.current >= self.max_exclusive { return None; } - let result = JoinBitmask(self.current); + let result = TableMask::from_bits(self.current); // Gosper's hack: compute next k-bit combination in lexicographic order let c = self.current & (!self.current + 1); // rightmost set bit @@ -396,16 +335,15 @@ fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> /// Check if the plan's row iteration order matches the [OrderTarget]'s column order fn plan_satisfies_order_target( plan: &JoinN, + access_methods_arena: &RefCell>, table_references: &[TableReference], - access_methods_cache: &HashMap, order_target: &OrderTarget, ) -> bool { let mut target_col_idx = 0; - for table_no in plan.table_numbers.iter() { + for (i, table_no) in plan.table_numbers.iter().enumerate() { let table_ref = &table_references[*table_no]; // Check if this table has an access method that provides ordering - let access_method = &plan.best_access_methods[target_col_idx]; - let access_method = access_methods_cache.get(access_method).unwrap(); + let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; match &access_method.kind { AccessMethodKind::Scan { index: None, @@ -461,13 +399,12 @@ fn plan_satisfies_order_target( } } AccessMethodKind::Search { - search: Search::Seek { index, seek_def }, - .. + index, iter_dir, .. } => { if let Some(index) = index { for index_col in index.columns.iter() { let target_col = &order_target.0[target_col_idx]; - let order_matches = if seek_def.iter_dir == IterationDirection::Forwards { + let order_matches = if *iter_dir == IterationDirection::Forwards { target_col.order == index_col.order } else { target_col.order != index_col.order @@ -484,50 +421,30 @@ fn plan_satisfies_order_target( } } } else { - // same as table scan - let iter_dir = seek_def.iter_dir; - for i in 0..table_ref.table.columns().len() { - let target_col = &order_target.0[target_col_idx]; - let order_matches = if iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order == SortOrder::Desc - }; - if target_col.table_no != *table_no - || target_col.column_no != i - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; } - } - } - AccessMethodKind::Search { - search: Search::RowidEq { .. }, - .. - } => { - let rowid_alias_col = table_ref - .table - .columns() - .iter() - .position(|c| c.is_rowid_alias); - let Some(rowid_alias_col) = rowid_alias_col else { - return false; - }; - let target_col = &order_target.0[target_col_idx]; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || target_col.order != SortOrder::Asc - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; } } } @@ -546,12 +463,12 @@ struct BestJoinOrderResult { /// Compute the best way to join a given set of tables. /// Returns the best [JoinN] if one exists, otherwise returns None. -fn compute_best_join_order( +fn compute_best_join_order<'a>( table_references: &[TableReference], - available_indexes: &HashMap>>, where_clause: &Vec, maybe_order_target: Option<&OrderTarget>, - access_methods_cache: &mut HashMap, + constraints: &'a [Constraints], + access_methods_arena: &'a RefCell>>, ) -> Result> { // Skip work if we have no tables to consider. if table_references.is_empty() { @@ -563,10 +480,10 @@ fn compute_best_join_order( // Compute naive left-to-right plan to use as pruning threshold let naive_plan = compute_naive_left_deep_plan( table_references, - available_indexes, where_clause, maybe_order_target, - access_methods_cache, + access_methods_arena, + &constraints, )?; // Keep track of both 1. the best plan overall (not considering sorting), and 2. the best ordered plan (which might not be the same). @@ -576,8 +493,8 @@ fn compute_best_join_order( let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { plan_satisfies_order_target( &naive_plan, + &access_methods_arena, table_references, - access_methods_cache, order_target, ) } else { @@ -616,19 +533,14 @@ fn compute_best_join_order( // if we find that 'b JOIN a' is better than 'a JOIN b', then we don't need to even try // to do 'a JOIN b JOIN c', because we know 'b JOIN a JOIN c' is going to be better. // This is due to the commutativity and associativity of inner joins. - let mut best_plan_memo: HashMap = HashMap::new(); + let mut best_plan_memo: HashMap = HashMap::new(); // Dynamic programming base case: calculate the best way to access each single table, as if // there were no other tables. for i in 0..num_tables { - let mut mask = JoinBitmask::new(0); - mask.set(i); + let mut mask = TableMask::new(); + mask.add_table(i); let table_ref = &table_references[i]; - let placeholder = vec![]; - let mut indexes_ref = &placeholder; - if let Some(indexes) = available_indexes.get(table_ref.table.get_name()) { - indexes_ref = indexes; - } join_order[0] = JoinOrderMember { table_no: i, is_outer: false, @@ -639,10 +551,10 @@ fn compute_best_join_order( i, table_ref, where_clause, - indexes_ref, + &constraints, &join_order, maybe_order_target, - access_methods_cache, + access_methods_arena, )?; best_plan_memo.insert(mask, rel); } @@ -661,7 +573,7 @@ fn compute_best_join_order( None } else { // map from rhs table index to lhs table index - let mut left_join_illegal_map: HashMap = + let mut left_join_illegal_map: HashMap = HashMap::with_capacity(left_join_count); for (i, _) in table_references.iter().enumerate() { for j in i + 1..table_references.len() { @@ -672,9 +584,11 @@ fn compute_best_join_order( { // bitwise OR the masks if let Some(illegal_lhs) = left_join_illegal_map.get_mut(&i) { - illegal_lhs.set(j); + illegal_lhs.add_table(j); } else { - left_join_illegal_map.insert(i, JoinBitmask::new(1 << j)); + let mut mask = TableMask::new(); + mask.add_table(j); + left_join_illegal_map.insert(i, mask); } } } @@ -700,16 +614,14 @@ fn compute_best_join_order( // Try to join all subsets (masks) with all other tables. // In this block, LHS is always (n-1) tables, and RHS is a single table. for rhs_idx in 0..num_tables { - let rhs_bit = 1 << rhs_idx; - // If the RHS table isn't a member of this join subset, skip. - if mask.0 & rhs_bit == 0 { + if !mask.contains_table(rhs_idx) { continue; } // If there are no other tables except RHS, skip. - let lhs_mask = JoinBitmask(mask.0 ^ rhs_bit); - if lhs_mask.0 == 0 { + let lhs_mask = mask.without_table(rhs_idx); + if lhs_mask.is_empty() { continue; } @@ -729,12 +641,6 @@ fn compute_best_join_order( let Some(lhs) = best_plan_memo.get(&lhs_mask) else { continue; }; - let rhs_ref = &table_references[rhs_idx]; - let placeholder = vec![]; - let mut indexes_ref = &placeholder; - if let Some(indexes) = available_indexes.get(rhs_ref.table.get_name()) { - indexes_ref = indexes; - } // Build a JoinOrder out of the table bitmask we are now considering. for table_no in lhs.table_numbers.iter() { @@ -759,12 +665,12 @@ fn compute_best_join_order( let rel = join_lhs_tables_to_rhs_table( Some(lhs), rhs_idx, - rhs_ref, + &table_references[rhs_idx], where_clause, - indexes_ref, + &constraints, &join_order, maybe_order_target, - access_methods_cache, + access_methods_arena, )?; join_order.clear(); @@ -778,8 +684,8 @@ fn compute_best_join_order( let satisfies_order_target = if let Some(ref order_target) = maybe_order_target { plan_satisfies_order_target( &rel, + &access_methods_arena, table_references, - access_methods_cache, order_target, ) } else { @@ -807,7 +713,7 @@ fn compute_best_join_order( if let Some(rel) = best_ordered_for_mask.take() { let cost = rel.cost; - let has_all_tables = mask.0.count_ones() as usize == num_tables; + let has_all_tables = mask.table_count() == num_tables; if has_all_tables && cost_upper_bound_ordered > cost { best_ordered_plan = Some(rel); } @@ -815,7 +721,7 @@ fn compute_best_join_order( if let Some(rel) = best_for_mask.take() { let cost = rel.cost; - let has_all_tables = mask.0.count_ones() as usize == num_tables; + let has_all_tables = mask.table_count() == num_tables; if has_all_tables { if cost_upper_bound > cost { best_plan = rel; @@ -841,12 +747,12 @@ fn compute_best_join_order( /// Specialized version of [compute_best_join_order] that just joins tables in the order they are given /// in the SQL query. This is used as an upper bound for any other plans -- we can give up enumerating /// permutations if they exceed this cost during enumeration. -fn compute_naive_left_deep_plan( +fn compute_naive_left_deep_plan<'a>( table_references: &[TableReference], - available_indexes: &HashMap>>, where_clause: &Vec, maybe_order_target: Option<&OrderTarget>, - access_methods_cache: &mut HashMap, + access_methods_arena: &'a RefCell>>, + constraints: &'a [Constraints], ) -> Result { let n = table_references.len(); assert!(n > 0); @@ -861,37 +767,28 @@ fn compute_naive_left_deep_plan( .collect::>(); // Start with first table - let placeholder = vec![]; - let mut indexes_ref = &placeholder; - if let Some(indexes) = available_indexes.get(table_references[0].table.get_name()) { - indexes_ref = indexes; - } let mut best_plan = join_lhs_tables_to_rhs_table( None, 0, &table_references[0], where_clause, - indexes_ref, + constraints, &join_order[..1], maybe_order_target, - access_methods_cache, + access_methods_arena, )?; // Add remaining tables one at a time from left to right for i in 1..n { - let mut indexes_ref = &placeholder; - if let Some(indexes) = available_indexes.get(table_references[i].table.get_name()) { - indexes_ref = indexes; - } best_plan = join_lhs_tables_to_rhs_table( Some(&best_plan), i, &table_references[i], where_clause, - indexes_ref, + constraints, &join_order[..i + 1], maybe_order_target, - access_methods_cache, + access_methods_arena, )?; } @@ -1026,14 +923,16 @@ fn use_indexes( order_by: &mut Option>, group_by: &mut Option, ) -> Result>> { - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); let maybe_order_target = compute_order_target(order_by, group_by.as_mut()); + let constraints = + constraints_from_where_clause(where_clause, table_references, available_indexes)?; let Some(best_join_order_result) = compute_best_join_order( table_references, - available_indexes, where_clause, maybe_order_target.as_ref(), - &mut access_methods_cache, + &constraints, + &access_methods_arena, )? else { return Ok(None); @@ -1062,8 +961,8 @@ fn use_indexes( if let Some(order_target) = maybe_order_target { let satisfies_order_target = plan_satisfies_order_target( &best_plan, + &access_methods_arena, table_references, - &mut access_methods_cache, &order_target, ); if satisfies_order_target { @@ -1082,41 +981,81 @@ fn use_indexes( } } - let (mut best_access_methods, best_table_numbers) = { - let mut kinds = Vec::with_capacity(best_plan.best_access_methods.len()); - for am_idx in best_plan.best_access_methods.iter() { - // take value from cache - let am = access_methods_cache.remove(am_idx).unwrap(); - kinds.push(am.kind); - } - (kinds, best_plan.table_numbers) - }; + let (best_access_methods, best_table_numbers) = + (best_plan.best_access_methods, best_plan.table_numbers); let mut to_remove_from_where_clause = vec![]; - for table_number in best_table_numbers.iter().rev() { - let access_method = best_access_methods.pop().unwrap(); + for (i, table_number) in best_table_numbers.iter().enumerate() { + let access_method_kind = access_methods_arena.borrow()[best_access_methods[i]] + .kind + .clone(); if matches!( table_references[*table_number].op, Operation::Subquery { .. } ) { // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. assert!( - matches!(access_method, AccessMethodKind::Scan { index: None, .. }), + matches!(access_method_kind, AccessMethodKind::Scan { index: None, .. }), "nothing in the current optimizer should be able to optimize subqueries, but got {:?} for table {}", - access_method, + access_method_kind, table_references[*table_number].table.get_name() ); continue; } - table_references[*table_number].op = match access_method { + table_references[*table_number].op = match access_method_kind { AccessMethodKind::Scan { iter_dir, index } => Operation::Scan { iter_dir, index }, AccessMethodKind::Search { - search, + index, constraints, + iter_dir, } => { + assert!(!constraints.is_empty()); for constraint in constraints.iter() { - to_remove_from_where_clause.push(constraint.position_in_where_clause.0); + to_remove_from_where_clause.push(constraint.where_clause_position.0); + } + if let Some(index) = index { + Operation::Search(Search::Seek { + index: Some(index), + seek_def: build_seek_def_from_constraints( + constraints, + iter_dir, + where_clause, + )?, + }) + } else { + assert!( + constraints.len() == 1, + "expected exactly one constraint for rowid seek, got {:?}", + constraints + ); + match constraints[0].operator { + ast::Operator::Equals => Operation::Search(Search::RowidEq { + cmp_expr: { + let (idx, side) = constraints[0].where_clause_position; + let ast::Expr::Binary(lhs, _, rhs) = + unwrap_parens(&where_clause[idx].expr)? + else { + panic!("Expected a binary expression"); + }; + let where_term = WhereTerm { + expr: match side { + BinaryExprSide::Lhs => lhs.as_ref().clone(), + BinaryExprSide::Rhs => rhs.as_ref().clone(), + }, + from_outer_join: where_clause[idx].from_outer_join.clone(), + }; + where_term + }, + }), + _ => Operation::Search(Search::Seek { + index: None, + seek_def: build_seek_def_from_constraints( + constraints, + iter_dir, + where_clause, + )?, + }), + } } - Operation::Search(search) } }; } @@ -1251,21 +1190,10 @@ pub trait Optimizable { .map_or(false, |c| c == AlwaysTrueOrFalse::AlwaysFalse)) } fn is_constant(&self, resolver: &Resolver<'_>) -> bool; - fn is_rowid_alias_of(&self, table_index: usize) -> bool; fn is_nonnull(&self, tables: &[TableReference]) -> bool; } impl Optimizable for ast::Expr { - fn is_rowid_alias_of(&self, table_index: usize) -> bool { - match self { - Self::Column { - table, - is_rowid_alias, - .. - } => *is_rowid_alias && *table == table_index, - _ => false, - } - } /// Returns true if the expressions is (verifiably) non-NULL. /// It might still be non-NULL even if we return false; we just /// weren't able to prove it. @@ -1562,23 +1490,6 @@ fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { } } -/// Struct used for scoring index candidates -/// Currently we just estimate cost in a really dumb way, -/// i.e. no statistics are used. -#[derive(Debug, Clone)] -pub struct IndexCandidate { - /// The index that we are considering. Can be None e.g. in case of table scan or rowid-based search. - index: Option>, - /// The search that we are considering, e.g. an index seek. Can be None if it's a table-scan or index-scan with no seek. - search: Option, - /// The direction of iteration. - iter_dir: IterationDirection, - /// The estimated cost of the scan or seek. - cost: ScanCost, - /// The constraints involved -- these are tracked so they can be removed from the where clause if this candidate is selected. - constraints: Vec, -} - /// A simple newtype wrapper over a f64 that represents the cost of an operation. /// /// This is used to estimate the cost of scans, seeks, and joins. @@ -1609,7 +1520,7 @@ struct IndexInfo { } #[derive(Debug, Clone, Copy, PartialEq)] -struct ScanCost { +pub struct ScanCost { run_cost: Cost, build_cost: Cost, } @@ -1644,7 +1555,7 @@ fn estimate_page_io_cost(rowcount: f64) -> Cost { /// based on the number of rows read, ignoring any CPU costs. fn estimate_cost_for_scan_or_seek( index_info: Option, - constraints: &[IndexConstraint], + constraints: &[Constraint], is_ephemeral: bool, input_cardinality: f64, ) -> ScanCost { @@ -1715,70 +1626,105 @@ fn estimate_cost_for_scan_or_seek( } } -/// Try to extract an index search from the WHERE clause -/// Returns an optional [Search] struct if an index search can be extracted, otherwise returns None. -pub fn try_extract_index_search_from_where_clause( - where_clause: &[WhereTerm], - loop_index: usize, +fn usable_constraints_for_join_order<'a>( + cs: &'a [Constraint], + table_index: usize, + join_order: &[JoinOrderMember], +) -> &'a [Constraint] { + let mut usable_until = 0; + for constraint in cs.iter() { + let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); + if other_side_refers_to_self { + break; + } + let lhs_mask = TableMask::from_iter( + join_order + .iter() + .take(join_order.len() - 1) + .map(|j| j.table_no), + ); + let all_required_tables_are_on_left_side = lhs_mask.contains_all(&constraint.lhs_mask); + if !all_required_tables_are_on_left_side { + break; + } + usable_until += 1; + } + &cs[..usable_until] +} + +/// Return the best [AccessMethod] for a given join order. +/// table_index and table_reference refer to the rightmost table in the join order. +pub fn find_best_access_method_for_join_order<'a>( table_index: usize, table_reference: &TableReference, - table_indexes: &[Arc], + constraints: &'a [Constraints], join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, input_cardinality: f64, -) -> Result> { - // Find all potential index constraints - // For WHERE terms to be used to constrain an index scan, they must: - // 1. refer to columns in the table that the index is on - // 2. be a binary comparison expression - // 3. constrain the index columns in the order that they appear in the index - // - e.g. if the index is on (a,b,c) then we can use all of "a = 1 AND b = 2 AND c = 3" to constrain the index scan, - // - but if the where clause is "a = 1 and c = 3" then we can only use "a = 1". +) -> Result> { let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], false, input_cardinality); - let mut constraints_cur = vec![]; - let mut best_index = IndexCandidate { - index: None, - search: None, - iter_dir: IterationDirection::Forwards, + let mut best_access_method = AccessMethod { cost: cost_of_full_table_scan, - constraints: vec![], + kind: AccessMethodKind::Scan { + index: None, + iter_dir: IterationDirection::Forwards, + }, }; - - for index in table_indexes { - // Check how many terms in the where clause constrain the index in column order - find_index_constraints( - where_clause, - loop_index, - table_index, - index, - join_order, - &mut constraints_cur, - )?; - // naive scoring since we don't have statistics: prefer the index where we can use the most columns - // e.g. if we can use all columns of an index on (a,b), it's better than an index of (c,d,e) where we can only use c. - let cost = estimate_cost_for_scan_or_seek( - Some(IndexInfo { + let rowid_column_idx = table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + for csmap in constraints + .iter() + .filter(|csmap| csmap.table_no == table_index) + { + let index_info = match csmap.index.as_ref() { + Some(index) => IndexInfo { unique: index.unique, - covering: table_reference.index_is_covering(index.as_ref()), + covering: table_reference.index_is_covering(index), column_count: index.columns.len(), - }), - &constraints_cur, + }, + None => IndexInfo { + unique: true, // rowids are always unique + covering: false, + column_count: 1, + }, + }; + let usable_constraints = + usable_constraints_for_join_order(&csmap.constraints, table_index, join_order); + let cost = estimate_cost_for_scan_or_seek( + Some(index_info), + &usable_constraints, false, input_cardinality, ); + let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { let mut all_same_direction = true; let mut all_opposite_direction = true; - for i in 0..order_target.0.len().min(index.columns.len()) { - if order_target.0[i].table_no != table_index - || order_target.0[i].column_no != index.columns[i].pos_in_table - { + for i in 0..order_target.0.len().min(index_info.column_count) { + let correct_table = order_target.0[i].table_no == table_index; + let correct_column = { + match csmap.index.as_ref() { + Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, + None => { + rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) + } + } + }; + if !correct_table || !correct_column { all_same_direction = false; all_opposite_direction = false; break; } - if order_target.0[i].order == index.columns[i].order { + let correct_order = { + match csmap.index.as_ref() { + Some(index) => order_target.0[i].order == index.columns[i].order, + None => order_target.0[i].order == SortOrder::Asc, + } + }; + if correct_order { all_opposite_direction = false; } else { all_same_direction = false; @@ -1792,56 +1738,64 @@ pub fn try_extract_index_search_from_where_clause( } else { Cost(0.0) }; - if cost.total() < best_index.cost.total() + order_satisfiability_bonus { - best_index.index = Some(Arc::clone(index)); - best_index.cost = cost; - best_index.constraints.clear(); - best_index.constraints.append(&mut constraints_cur); + if cost.total() < best_access_method.cost.total() + order_satisfiability_bonus { + best_access_method.cost = cost; + best_access_method.set_constraints(csmap.index.clone(), &usable_constraints); } } - // We haven't found a persistent btree index that is any better than a full table scan; - // let's see if building an ephemeral index would be better. - if best_index.index.is_none() && matches!(table_reference.table, Table::BTree(_)) { - let (ephemeral_cost, constraints_with_col_idx, mut constraints_without_col_idx) = - ephemeral_index_estimate_cost( - where_clause, - table_reference, - loop_index, - table_index, - join_order, - input_cardinality, - ); - if ephemeral_cost.total() < best_index.cost.total() { - // ephemeral index makes sense, so let's build it now. - // ephemeral columns are: columns from the table_reference, constraints first, then the rest - let ephemeral_index = - ephemeral_index_build(table_reference, table_index, &constraints_with_col_idx); - best_index.index = Some(Arc::new(ephemeral_index)); - best_index.cost = ephemeral_cost; - best_index.constraints.clear(); - best_index - .constraints - .append(&mut constraints_without_col_idx); - } - } + // FIXME: ephemeral indexes are disabled for now. + // These constraints need to be computed ad hoc. + // // We haven't found a persistent btree index that is any better than a full table scan; + // // let's see if building an ephemeral index would be better. + // if best_index.index.is_none() && matches!(table_reference.table, Table::BTree(_)) { + // let (ephemeral_cost, constraints_with_col_idx) = ephemeral_index_estimate_cost( + // where_clause, + // table_reference, + // loop_index, + // table_index, + // join_order, + // input_cardinality, + // )?; + // if ephemeral_cost.total() < best_index.cost.total() { + // // ephemeral index makes sense, so let's build it now. + // // ephemeral columns are: columns from the table_reference, constraints first, then the rest + // let ephemeral_index = + // ephemeral_index_build(table_reference, table_index, &constraints_with_col_idx); + // best_index.index = Some(Arc::new(ephemeral_index)); + // best_index.cost = ephemeral_cost; + // } + // } - if best_index.index.is_none() { - return Ok(None); - } - - best_index.iter_dir = if let Some(order_target) = maybe_order_target { + let iter_dir = if let Some(order_target) = maybe_order_target { // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards - let index = best_index.index.as_ref().unwrap(); + let index = match &best_access_method.kind { + AccessMethodKind::Scan { index, .. } => index.as_ref(), + AccessMethodKind::Search { index, .. } => index.as_ref(), + }; let mut should_use_backwards = true; - for i in 0..order_target.0.len().min(index.columns.len()) { - if order_target.0[i].table_no != table_index - || order_target.0[i].column_no != index.columns[i].pos_in_table - { + let num_cols = index.map_or(1, |i| i.columns.len()); + for i in 0..order_target.0.len().min(num_cols) { + let correct_table = order_target.0[i].table_no == table_index; + let correct_column = { + match index { + Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, + None => { + rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) + } + } + }; + if !correct_table || !correct_column { should_use_backwards = false; break; } - if order_target.0[i].order == index.columns[i].order { + let correct_order = { + match index { + Some(index) => order_target.0[i].order == index.columns[i].order, + None => order_target.0[i].order == SortOrder::Asc, + } + }; + if correct_order { should_use_backwards = false; break; } @@ -1854,34 +1808,12 @@ pub fn try_extract_index_search_from_where_clause( } else { IterationDirection::Forwards }; + best_access_method.set_iter_dir(iter_dir); - if best_index.constraints.is_empty() { - return Ok(Some(best_index)); - } - - // Build the seek definition - let seek_def = build_seek_def_from_index_constraints( - &best_index.constraints, - best_index.iter_dir, - where_clause, - )?; - - // Remove the used terms from the where_clause since they are now part of the seek definition - // Sort terms by position in descending order to avoid shifting indices during removal - best_index.constraints.sort_by(|a, b| { - b.position_in_where_clause - .0 - .cmp(&a.position_in_where_clause.0) - }); - - best_index.search = Some(Search::Seek { - index: best_index.index.as_ref().cloned(), - seek_def, - }); - - return Ok(Some(best_index)); + Ok(best_access_method) } +// TODO get rid of doing this every time fn ephemeral_index_estimate_cost( where_clause: &[WhereTerm], table_reference: &TableReference, @@ -1889,12 +1821,8 @@ fn ephemeral_index_estimate_cost( table_index: usize, join_order: &[JoinOrderMember], input_cardinality: f64, -) -> ( - ScanCost, - Vec<(usize, IndexConstraint)>, - Vec, -) { - let mut constraints_with_col_idx: Vec<(usize, IndexConstraint)> = where_clause +) -> Result<(ScanCost, Vec<(usize, Constraint)>)> { + let mut constraints_with_col_idx: Vec<(usize, Constraint)> = where_clause .iter() .enumerate() .filter(|(_, term)| is_potential_index_constraint(term, loop_index, join_order)) @@ -1904,24 +1832,34 @@ fn ephemeral_index_estimate_cost( }; if let ast::Expr::Column { table, column, .. } = lhs.as_ref() { if *table == table_index { + let Ok(constraining_table_mask) = table_mask_from_expr(rhs) else { + return None; + }; return Some(( *column, - IndexConstraint { - position_in_where_clause: (i, BinaryExprSide::Rhs), + Constraint { + where_clause_position: (i, BinaryExprSide::Rhs), operator: *operator, - index_column_sort_order: SortOrder::Asc, + key_position: *column, + sort_order: SortOrder::Asc, + lhs_mask: constraining_table_mask, }, )); } } if let ast::Expr::Column { table, column, .. } = rhs.as_ref() { if *table == table_index { + let Ok(constraining_table_mask) = table_mask_from_expr(lhs) else { + return None; + }; return Some(( *column, - IndexConstraint { - position_in_where_clause: (i, BinaryExprSide::Lhs), + Constraint { + where_clause_position: (i, BinaryExprSide::Lhs), operator: opposite_cmp_op(*operator), - index_column_sort_order: SortOrder::Asc, + key_position: *column, + sort_order: SortOrder::Asc, + lhs_mask: constraining_table_mask, }, )); } @@ -1945,14 +1883,13 @@ fn ephemeral_index_estimate_cost( .unwrap_or(constraints_with_col_idx.len()), ); if constraints_with_col_idx.is_empty() { - return ( + return Ok(( ScanCost { run_cost: Cost(0.0), build_cost: Cost(f64::MAX), }, vec![], - vec![], - ); + )); } let ephemeral_column_count = table_reference @@ -1966,7 +1903,7 @@ fn ephemeral_index_estimate_cost( .iter() .cloned() .map(|(_, c)| c) - .collect::>(); + .collect::>(); let ephemeral_cost = estimate_cost_for_scan_or_seek( Some(IndexInfo { unique: false, @@ -1977,17 +1914,13 @@ fn ephemeral_index_estimate_cost( true, input_cardinality, ); - ( - ephemeral_cost, - constraints_with_col_idx, - constraints_without_col_idx, - ) + Ok((ephemeral_cost, constraints_with_col_idx)) } fn ephemeral_index_build( table_reference: &TableReference, table_index: usize, - index_constraints: &[(usize, IndexConstraint)], + constraints: &[(usize, Constraint)], ) -> Index { let mut ephemeral_columns: Vec = table_reference .columns() @@ -2003,11 +1936,11 @@ fn ephemeral_index_build( .collect(); // sort so that constraints first, then rest in whatever order they were in in the table ephemeral_columns.sort_by(|a, b| { - let a_constraint = index_constraints + let a_constraint = constraints .iter() .enumerate() .find(|(_, c)| c.0 == a.pos_in_table); - let b_constraint = index_constraints + let b_constraint = constraints .iter() .enumerate() .find(|(_, c)| c.0 == b.pos_in_table); @@ -2035,15 +1968,261 @@ fn ephemeral_index_build( } #[derive(Debug, Clone)] -/// A representation of an expression in a [WhereTerm] that can potentially be used as part of an index seek key. -/// For example, if there is an index on table T(x,y) and another index on table U(z), and the where clause is "WHERE x > 10 AND 20 = z", -/// the index constraints are: -/// - x > 10 ==> IndexConstraint { position_in_where_clause: (0, [BinaryExprSide::Rhs]), operator: [ast::Operator::Greater] } -/// - 20 = z ==> IndexConstraint { position_in_where_clause: (1, [BinaryExprSide::Lhs]), operator: [ast::Operator::Equals] } -pub struct IndexConstraint { - position_in_where_clause: (usize, BinaryExprSide), +pub struct Constraint { + /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), + /// since the RHS '10' is the constraining expression and it's part of the second term in the WHERE clause. + where_clause_position: (usize, BinaryExprSide), + /// The operator of the constraint, e.g. =, >, < operator: ast::Operator, - index_column_sort_order: SortOrder, + /// The position of the index column in the index, e.g. if the index is (a,b,c) and the constraint is on b, then index_column_pos is 1. + /// For Rowid constraints this is always 0. + key_position: usize, + /// The sort order of the index column, ASC or DESC. For Rowid constraints this is always ASC. + sort_order: SortOrder, + /// Bitmask of tables that are required to be on the left side of the constrained table, + /// e.g. in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, the lhs_mask contains t2 and t3. + lhs_mask: TableMask, +} + +#[derive(Debug)] +/// A collection of [Constraint]s for a given (table, index) pair. +pub struct Constraints { + index: Option>, + table_no: usize, + constraints: Vec, +} + +/// Precompute all potentially usable constraints from a WHERE clause. +/// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. +pub fn constraints_from_where_clause( + where_clause: &[WhereTerm], + table_references: &[TableReference], + available_indexes: &HashMap>>, +) -> Result> { + let mut constraints = Vec::new(); + for (table_no, table_reference) in table_references.iter().enumerate() { + let rowid_alias_column = table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + + if let Some(rowid_alias_column) = rowid_alias_column { + let mut cs = Constraints { + index: None, + table_no, + constraints: Vec::new(), + }; + for (i, term) in where_clause.iter().enumerate() { + let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { + continue; + }; + if !matches!( + operator, + ast::Operator::Equals + | ast::Operator::Greater + | ast::Operator::Less + | ast::Operator::GreaterEquals + | ast::Operator::LessEquals + ) { + continue; + } + if let Some(outer_join_tbl) = term.from_outer_join { + if outer_join_tbl != table_no { + continue; + } + } + match lhs.as_ref() { + ast::Expr::Column { table, column, .. } => { + if *table == table_no && *column == rowid_alias_column { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Rhs), + operator: *operator, + key_position: 0, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Rhs), + operator: *operator, + key_position: 0, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + } + _ => {} + }; + match rhs.as_ref() { + ast::Expr::Column { table, column, .. } => { + if *table == table_no && *column == rowid_alias_column { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + key_position: 0, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + key_position: 0, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + _ => {} + }; + } + // First sort by position, with equalities first within each position + cs.constraints.sort_by(|a, b| { + let pos_cmp = a.key_position.cmp(&b.key_position); + if pos_cmp == Ordering::Equal { + // If same position, sort equalities first + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + } else { + pos_cmp + } + }); + + // Deduplicate by position, keeping first occurrence (which will be equality if one exists) + cs.constraints.dedup_by_key(|c| c.key_position); + + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for constraint in cs.constraints.iter() { + if constraint.key_position != last_pos { + if constraint.key_position != last_pos + 1 { + break; + } + last_pos = constraint.key_position; + } + i += 1; + } + cs.constraints.truncate(i); + + // Truncate after the first inequality + if let Some(first_inequality) = cs + .constraints + .iter() + .position(|c| c.operator != ast::Operator::Equals) + { + cs.constraints.truncate(first_inequality + 1); + } + constraints.push(cs); + } + let indexes = available_indexes.get(table_reference.table.get_name()); + if let Some(indexes) = indexes { + for index in indexes { + let mut cs = Constraints { + index: Some(index.clone()), + table_no, + constraints: Vec::new(), + }; + for (i, term) in where_clause.iter().enumerate() { + let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { + continue; + }; + if !matches!( + operator, + ast::Operator::Equals + | ast::Operator::Greater + | ast::Operator::Less + | ast::Operator::GreaterEquals + | ast::Operator::LessEquals + ) { + continue; + } + if let Some(outer_join_tbl) = term.from_outer_join { + if outer_join_tbl != table_no { + continue; + } + } + if let Some(position_in_index) = + get_column_position_in_index(lhs, table_no, index)? + { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Rhs), + operator: *operator, + key_position: position_in_index, + sort_order: index.columns[position_in_index].order, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + if let Some(position_in_index) = + get_column_position_in_index(rhs, table_no, index)? + { + cs.constraints.push(Constraint { + where_clause_position: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + key_position: position_in_index, + sort_order: index.columns[position_in_index].order, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + // First sort by position, with equalities first within each position + cs.constraints.sort_by(|a, b| { + let pos_cmp = a.key_position.cmp(&b.key_position); + if pos_cmp == Ordering::Equal { + // If same position, sort equalities first + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + } else { + pos_cmp + } + }); + + // Deduplicate by position, keeping first occurrence (which will be equality if one exists) + cs.constraints.dedup_by_key(|c| c.key_position); + + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for constraint in cs.constraints.iter() { + if constraint.key_position != last_pos { + if constraint.key_position != last_pos + 1 { + break; + } + last_pos = constraint.key_position; + } + i += 1; + } + cs.constraints.truncate(i); + + // Truncate after the first inequality + if let Some(first_inequality) = cs + .constraints + .iter() + .position(|c| c.operator != ast::Operator::Equals) + { + cs.constraints.truncate(first_inequality + 1); + } + constraints.push(cs); + } + } + } + Ok(constraints) } /// Helper enum for [IndexConstraint] to indicate which side of a binary comparison expression is being compared to the index column. @@ -2156,91 +2335,22 @@ fn is_potential_index_constraint( true } -/// Find all [IndexConstraint]s for a given WHERE clause -/// Constraints are appended as long as they constrain the index in column order. -/// E.g. for index (a,b,c) to be fully used, there must be a [WhereTerm] for each of a, b, and c. -/// If e.g. only a and c are present, then only the first column 'a' of the index will be used. -fn find_index_constraints( - where_clause: &[WhereTerm], - loop_index: usize, - table_index: usize, - index: &Arc, - join_order: &[JoinOrderMember], - out_constraints: &mut Vec, -) -> Result<()> { - for position_in_index in 0..index.columns.len() { - let mut found = false; - for (position_in_where_clause, term) in where_clause.iter().enumerate() { - if !is_potential_index_constraint(term, loop_index, join_order) { - continue; - } - - let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { - panic!("expected binary expression"); - }; - - // Check if lhs is a column that is in the i'th position of the index - if Some(position_in_index) == get_column_position_in_index(lhs, table_index, index)? { - out_constraints.push(IndexConstraint { - operator: *operator, - position_in_where_clause: (position_in_where_clause, BinaryExprSide::Rhs), - index_column_sort_order: index.columns[position_in_index].order, - }); - found = true; - break; - } - // Check if rhs is a column that is in the i'th position of the index - if Some(position_in_index) == get_column_position_in_index(rhs, table_index, index)? { - out_constraints.push(IndexConstraint { - operator: opposite_cmp_op(*operator), // swap the operator since e.g. if condition is 5 >= x, we want to use x <= 5 - position_in_where_clause: (position_in_where_clause, BinaryExprSide::Lhs), - index_column_sort_order: index.columns[position_in_index].order, - }); - found = true; - break; - } - } - if !found { - // Expressions must constrain index columns in index definition order. If we didn't find a constraint for the i'th index column, - // then we stop here and return the constraints we have found so far. - break; - } - } - - // In a multicolumn index, only the last term can have a nonequality expression. - // For example, imagine an index on (x,y) and the where clause is "WHERE x > 10 AND y > 20"; - // We can't use GT(x: 10,y: 20) as the seek key, because the first row greater than (x: 10,y: 20) - // might be e.g. (x: 10,y: 21), which does not satisfy the where clause, but a row after that e.g. (x: 11,y: 21) does. - // So: - // - in this case only GT(x: 10) can be used as the seek key, and we must emit a regular condition expression for y > 20 while scanning. - // On the other hand, if the where clause is "WHERE x = 10 AND y > 20", we can use GT(x=10,y=20) as the seek key, - // because any rows where (x=10,y=20) < ROW < (x=11) will match the where clause. - for i in 0..out_constraints.len() { - if out_constraints[i].operator != ast::Operator::Equals { - out_constraints.truncate(i + 1); - break; - } - } - - Ok(()) -} - -/// Build a [SeekDef] for a given list of [IndexConstraint]s -pub fn build_seek_def_from_index_constraints( - constraints: &[IndexConstraint], +/// Build a [SeekDef] for a given list of [Constraint]s +pub fn build_seek_def_from_constraints( + constraints: &[Constraint], iter_dir: IterationDirection, where_clause: &[WhereTerm], ) -> Result { assert!( !constraints.is_empty(), - "cannot build seek def from empty list of index constraints" + "cannot build seek def from empty list of constraints" ); // Extract the key values and operators let mut key = Vec::with_capacity(constraints.len()); for constraint in constraints { // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) - let (idx, side) = constraint.position_in_where_clause; + let (idx, side) = constraint.where_clause_position; let where_term = &where_clause[idx]; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { crate::bail_parse_error!("expected binary expression"); @@ -2250,14 +2360,15 @@ pub fn build_seek_def_from_index_constraints( } else { *rhs }; - key.push((cmp_expr, constraint.index_column_sort_order)); + key.push((cmp_expr, constraint.sort_order)); } // We know all but potentially the last term is an equality, so we can use the operator of the last term // to form the SeekOp let op = constraints.last().unwrap().operator; - build_seek_def(op, iter_dir, key) + let seek_def = build_seek_def(op, iter_dir, key)?; + Ok(seek_def) } /// Build a [SeekDef] for a given comparison operator and index key. @@ -2683,177 +2794,6 @@ fn build_seek_def( }) } -pub fn try_extract_rowid_search_expression( - cond: &WhereTerm, - cond_idx: usize, - loop_idx: usize, - table_idx: usize, - table_reference: &TableReference, - join_order: &[JoinOrderMember], - maybe_order_target: Option<&OrderTarget>, - input_cardinality: f64, -) -> Result> { - if !cond.should_eval_at_loop(loop_idx, join_order) { - return Ok(None); - } - let iter_dir = if let Some(order_target) = maybe_order_target { - // if the order target 1. has a single column 2. it is the rowid alias of this table 3. the order target column is in descending order, then we should use IterationDirection::Backwards - let rowid_alias_column_no = table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - - let should_use_backwards = if let Some(rowid_alias_column_no) = rowid_alias_column_no { - order_target.0.len() == 1 - && order_target.0[0].table_no == table_idx - && order_target.0[0].column_no == rowid_alias_column_no - && order_target.0[0].order == SortOrder::Desc - } else { - false - }; - if should_use_backwards { - IterationDirection::Backwards - } else { - IterationDirection::Forwards - } - } else { - IterationDirection::Forwards - }; - match &cond.expr { - ast::Expr::Binary(lhs, operator, rhs) => { - // If both lhs and rhs refer to columns from this table, we can't perform a rowid seek - // Examples: - // - WHERE t.x > t.y - // - WHERE t.x + 1 > t.y - 5 - // - WHERE t.x = (t.x) - if determine_where_to_eval_expr(lhs, join_order)? == EvalAt::Loop(loop_idx) - && determine_where_to_eval_expr(rhs, join_order)? == EvalAt::Loop(loop_idx) - { - return Ok(None); - } - if lhs.is_rowid_alias_of(table_idx) { - match operator { - ast::Operator::Equals => { - let rhs_owned = rhs.as_ref().clone(); - return Ok(Some(IndexCandidate { - index: None, - iter_dir, - constraints: vec![IndexConstraint { - position_in_where_clause: (cond_idx, BinaryExprSide::Rhs), - operator: *operator, - index_column_sort_order: SortOrder::Asc, - }], - search: Some(Search::RowidEq { - cmp_expr: WhereTerm { - expr: rhs_owned, - from_outer_join: cond.from_outer_join, - }, - }), - cost: ScanCost { - run_cost: estimate_page_io_cost(3.0 * input_cardinality), // assume 3 page IOs to perform a seek - build_cost: Cost(0.0), - }, - })); - } - ast::Operator::Greater - | ast::Operator::GreaterEquals - | ast::Operator::Less - | ast::Operator::LessEquals => { - let rhs_owned = rhs.as_ref().clone(); - let range_selectivity = SELECTIVITY_RANGE; - let seek_def = - build_seek_def(*operator, iter_dir, vec![(rhs_owned, SortOrder::Asc)])?; - return Ok(Some(IndexCandidate { - index: None, - iter_dir, - constraints: vec![IndexConstraint { - position_in_where_clause: (cond_idx, BinaryExprSide::Rhs), - operator: *operator, - index_column_sort_order: SortOrder::Asc, - }], - search: Some(Search::Seek { - index: None, - seek_def, - }), - cost: ScanCost { - run_cost: estimate_page_io_cost( - ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - * range_selectivity - * input_cardinality, - ), - build_cost: Cost(0.0), - }, - })); - } - _ => {} - } - } - - if rhs.is_rowid_alias_of(table_idx) { - match operator { - ast::Operator::Equals => { - let lhs_owned = lhs.as_ref().clone(); - return Ok(Some(IndexCandidate { - index: None, - iter_dir, - constraints: vec![IndexConstraint { - position_in_where_clause: (cond_idx, BinaryExprSide::Lhs), - operator: *operator, - index_column_sort_order: SortOrder::Asc, - }], - search: Some(Search::RowidEq { - cmp_expr: WhereTerm { - expr: lhs_owned, - from_outer_join: cond.from_outer_join, - }, - }), - cost: ScanCost { - run_cost: estimate_page_io_cost(3.0 * input_cardinality), // assume 3 page IOs to perform a seek - build_cost: Cost(0.0), - }, - })); - } - ast::Operator::Greater - | ast::Operator::GreaterEquals - | ast::Operator::Less - | ast::Operator::LessEquals => { - let lhs_owned = lhs.as_ref().clone(); - let op = opposite_cmp_op(*operator); - let range_selectivity = SELECTIVITY_RANGE; - let seek_def = - build_seek_def(op, iter_dir, vec![(lhs_owned, SortOrder::Asc)])?; - return Ok(Some(IndexCandidate { - index: None, - iter_dir, - constraints: vec![IndexConstraint { - position_in_where_clause: (cond_idx, BinaryExprSide::Lhs), - operator: *operator, - index_column_sort_order: SortOrder::Asc, - }], - search: Some(Search::Seek { - index: None, - seek_def, - }), - cost: ScanCost { - run_cost: estimate_page_io_cost( - ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - * range_selectivity - * input_cardinality, - ), - build_cost: Cost(0.0), - }, - })); - } - _ => {} - } - } - - Ok(None) - } - _ => Ok(None), - } -} - pub fn rewrite_expr(expr: &mut ast::Expr, param_idx: &mut usize) -> Result<()> { match expr { ast::Expr::Id(id) => { @@ -3003,17 +2943,18 @@ mod tests { use crate::{ schema::{BTreeTable, Column, Table, Type}, translate::plan::{ColumnUsedMask, JoinInfo}, + translate::planner::TableMask, }; #[test] fn test_generate_bitmasks() { let bitmasks = generate_join_bitmasks(4, 2).collect::>(); - assert!(bitmasks.contains(&JoinBitmask(0b11))); // {0,1} - assert!(bitmasks.contains(&JoinBitmask(0b101))); // {0,2} - assert!(bitmasks.contains(&JoinBitmask(0b110))); // {1,2} - assert!(bitmasks.contains(&JoinBitmask(0b1001))); // {0,3} - assert!(bitmasks.contains(&JoinBitmask(0b1010))); // {1,3} - assert!(bitmasks.contains(&JoinBitmask(0b1100))); // {2,3} + assert!(bitmasks.contains(&TableMask(0b110))); // {0,1} -- first bit is always set to 0 so that a Mask with value 0 means "no tables are referenced". + assert!(bitmasks.contains(&TableMask(0b1010))); // {0,2} + assert!(bitmasks.contains(&TableMask(0b1100))); // {1,2} + assert!(bitmasks.contains(&TableMask(0b10010))); // {0,3} + assert!(bitmasks.contains(&TableMask(0b10100))); // {1,3} + assert!(bitmasks.contains(&TableMask(0b11000))); // {2,3} } #[test] @@ -3023,14 +2964,17 @@ mod tests { let available_indexes = HashMap::new(); let where_clause = vec![]; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let result = compute_best_join_order( &table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_none()); @@ -3044,22 +2988,25 @@ mod tests { let available_indexes = HashMap::new(); let where_clause = vec![]; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); // SELECT * from test_table // expecting best_best_plan() not to do any work due to empty where clause. let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap() .unwrap(); // Should just be a table scan access method assert!(matches!( - access_methods_cache[&best_plan.best_access_methods[0]].kind, + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); @@ -3077,16 +3024,20 @@ mod tests { _create_numeric_literal("42"), )]; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let available_indexes = HashMap::new(); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); // SELECT * FROM test_table WHERE id = 42 // expecting a RowidEq access method because id is a rowid alias. let result = compute_best_join_order( &table_references, - &HashMap::new(), &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_some()); @@ -3094,15 +3045,16 @@ mod tests { assert_eq!(best_plan.table_numbers, vec![0]); assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Search { - search: Search::RowidEq { cmp_expr }, + index: None, + iter_dir, constraints, } - if &cmp_expr.expr == &_create_numeric_literal("42") && constraints.len() == 1 && constraints[0].position_in_where_clause == (0, BinaryExprSide::Rhs), + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs), ), "expected rowid eq access method, got {:?}", - access_methods_cache[&best_plan.best_access_methods[0]].kind + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind ); } @@ -3121,8 +3073,7 @@ mod tests { _create_numeric_literal("42"), )]; - let mut access_methods_cache = HashMap::new(); - + let access_methods_arena = RefCell::new(Vec::new()); let mut available_indexes = HashMap::new(); let index = Arc::new(Index { name: "sqlite_autoindex_test_table_1".to_string(), @@ -3138,14 +3089,17 @@ mod tests { }); available_indexes.insert("test_table".to_string(), vec![index]); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); // SELECT * FROM test_table WHERE id = 42 // expecting an IndexScan access method because id is a primary key with an index let result = compute_best_join_order( &table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_some()); @@ -3153,15 +3107,16 @@ mod tests { assert_eq!(best_plan.table_numbers, vec![0]); assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Search { - search: Search::Seek { index, .. }, + index: Some(index), + iter_dir, constraints, } - if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "sqlite_autoindex_test_table_1") + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs), ), "expected index search access method, got {:?}", - access_methods_cache[&best_plan.best_access_methods[0]].kind + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind ); } @@ -3206,14 +3161,17 @@ mod tests { _create_column_expr(1, 0, false), // table2.id )]; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let result = compute_best_join_order( &mut table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_some()); @@ -3221,24 +3179,25 @@ mod tests { assert_eq!(best_plan.table_numbers, vec![1, 0]); assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), "expected TableScan access method, got {:?}", - access_methods_cache[&best_plan.best_access_methods[0]].kind + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind ); assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[1]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, AccessMethodKind::Search { - search: Search::Seek { index, .. }, + index: Some(index), + iter_dir, constraints, } - if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "index1") + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs) && index.name == "index1", ), "expected Search access method, got {:?}", - access_methods_cache[&best_plan.best_access_methods[1]].kind + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind ); } @@ -3370,14 +3329,17 @@ mod tests { ), ]; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let result = compute_best_join_order( &table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_some()); @@ -3389,32 +3351,47 @@ mod tests { vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] ); - assert!(matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - search: Search::Seek { index, .. }, - constraints, - } - if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "sqlite_autoindex_customers_1") - )); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_customers_1", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + ); - assert!(matches!( - &access_methods_cache[&best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - search: Search::Seek { index, .. }, - constraints, - } - if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "orders_customer_id_idx") - )); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_CUSTOMERS) && index.name == "orders_customer_id_idx", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + ); - assert!(matches!( - &access_methods_cache[&best_plan.best_access_methods[2]].kind, - AccessMethodKind::Search { - search: Search::Seek { index, .. }, - constraints, - } - if constraints.len() == 1 && index.as_ref().map_or(false, |i| i.name == "order_items_order_id_idx") - )); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_ORDERS) && index.name == "order_items_order_id_idx", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind + ); } struct TestColumn { @@ -3473,14 +3450,16 @@ mod tests { ]; let available_indexes = HashMap::new(); - let mut access_methods_cache = HashMap::new(); - + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &mut table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap() .unwrap(); @@ -3489,22 +3468,23 @@ mod tests { assert_eq!(best_plan.table_numbers[0], 1, "best_plan: {:?}", best_plan); // Verify table scan is used since there are no indexes assert!(matches!( - access_methods_cache[&best_plan.best_access_methods[0]].kind, + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that an ephemeral index was built on t1 assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[1]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, AccessMethodKind::Search { - search: Search::Seek { index, .. }, - .. + index: Some(index), + iter_dir, + constraints, } - if index.as_ref().map_or(false, |i| i.ephemeral) + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs) && index.ephemeral ), "expected ephemeral index, got {:?}", - access_methods_cache[&best_plan.best_access_methods[1]].kind + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind ); } @@ -3548,14 +3528,17 @@ mod tests { ]; let available_indexes = HashMap::new(); - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &mut table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap() .unwrap(); @@ -3564,19 +3547,19 @@ mod tests { assert_eq!(best_plan.table_numbers[0], 1); // Verify table scan is used since there are no indexes assert!(matches!( - access_methods_cache[&best_plan.best_access_methods[0]].kind, + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that t1 is chosen next due to its inequality filter assert!(matches!( - access_methods_cache[&best_plan.best_access_methods[1]].kind, + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); // Verify that t3 is chosen last due to no filters assert!(matches!( - access_methods_cache[&best_plan.best_access_methods[2]].kind, + access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, AccessMethodKind::Scan { index: None, iter_dir } if iter_dir == IterationDirection::Forwards )); @@ -3644,14 +3627,18 @@ mod tests { refs }; - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let available_indexes = HashMap::new(); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); let result = compute_best_join_order( &table_references, - &HashMap::new(), &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap(); assert!(result.is_some()); @@ -3668,7 +3655,7 @@ mod tests { // Verify access methods assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), @@ -3678,15 +3665,17 @@ mod tests { for i in 1..best_plan.table_numbers.len() { assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[i]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind, AccessMethodKind::Search { - search: Search::RowidEq { .. }, - .. + index: None, + iter_dir, + constraints, } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(FACT_TABLE_IDX) ), - "Table {} should use RowidEq access method, got {:?}", + "Table {} should use Search access method, got {:?}", i + 1, - &access_methods_cache[&best_plan.best_access_methods[i]].kind + &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind ); } } @@ -3726,15 +3715,18 @@ mod tests { )); } - let mut access_methods_cache = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); // Run the optimizer let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &table_references, - &available_indexes, &where_clause, None, - &mut access_methods_cache, + &constraints, + &access_methods_arena, ) .unwrap() .unwrap(); @@ -3752,7 +3744,7 @@ mod tests { // - First table should use Table scan assert!( matches!( - &access_methods_cache[&best_plan.best_access_methods[0]].kind, + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, AccessMethodKind::Scan { index: None, iter_dir } if *iter_dir == IterationDirection::Forwards ), @@ -3761,16 +3753,18 @@ mod tests { // all of the rest should use rowid equality for i in 1..NUM_TABLES { - let method = &access_methods_cache[&best_plan.best_access_methods[i]].kind; + let method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind; assert!( matches!( method, AccessMethodKind::Search { - search: Search::RowidEq { .. }, - .. + index: None, + iter_dir, + constraints, } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(i-1) ), - "Table {} should use RowidEq access method, got {:?}", + "Table {} should use Search access method, got {:?}", i + 1, method ); diff --git a/core/translate/planner.rs b/core/translate/planner.rs index e47bb1a49..decf44549 100644 --- a/core/translate/planner.rs +++ b/core/translate/planner.rs @@ -588,6 +588,195 @@ pub fn determine_where_to_eval_term( return determine_where_to_eval_expr(&term.expr, join_order); } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TableMask(pub u128); + +impl std::ops::BitOrAssign for TableMask { + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0; + } +} + +impl TableMask { + pub fn new() -> Self { + Self(0) + } + + pub fn is_empty(&self) -> bool { + self.0 == 0 + } + + pub fn without_table(&self, table_no: usize) -> Self { + assert!(table_no < 127, "table_no must be less than 127"); + Self(self.0 ^ (1 << (table_no + 1))) + } + + pub fn from_bits(bits: u128) -> Self { + Self(bits << 1) + } + + pub fn from_iter(iter: impl Iterator) -> Self { + iter.fold(Self::new(), |mut mask, table_no| { + assert!(table_no < 127, "table_no must be less than 127"); + mask.add_table(table_no); + mask + }) + } + + pub fn add_table(&mut self, table_no: usize) { + assert!(table_no < 127, "table_no must be less than 127"); + self.0 |= 1 << (table_no + 1); + } + + pub fn contains_table(&self, table_no: usize) -> bool { + assert!(table_no < 127, "table_no must be less than 127"); + self.0 & (1 << (table_no + 1)) != 0 + } + + pub fn contains_all(&self, other: &TableMask) -> bool { + self.0 & other.0 == other.0 + } + + pub fn table_count(&self) -> usize { + self.0.count_ones() as usize + } + + pub fn intersects(&self, other: &TableMask) -> bool { + self.0 & other.0 != 0 + } +} + +pub fn table_mask_from_expr(expr: &Expr) -> Result { + let mut mask = TableMask::new(); + match expr { + Expr::Binary(e1, _, e2) => { + mask |= table_mask_from_expr(e1)?; + mask |= table_mask_from_expr(e2)?; + } + Expr::Column { table, .. } | Expr::RowId { table, .. } => { + mask.add_table(*table); + } + Expr::Between { + lhs, + not: _, + start, + end, + } => { + mask |= table_mask_from_expr(lhs)?; + mask |= table_mask_from_expr(start)?; + mask |= table_mask_from_expr(end)?; + } + Expr::Case { + base, + when_then_pairs, + else_expr, + } => { + if let Some(base) = base { + mask |= table_mask_from_expr(base)?; + } + for (when, then) in when_then_pairs { + mask |= table_mask_from_expr(when)?; + mask |= table_mask_from_expr(then)?; + } + if let Some(else_expr) = else_expr { + mask |= table_mask_from_expr(else_expr)?; + } + } + Expr::Cast { expr, .. } => { + mask |= table_mask_from_expr(expr)?; + } + Expr::Collate(expr, _) => { + mask |= table_mask_from_expr(expr)?; + } + Expr::DoublyQualified(_, _, _) => { + crate::bail_parse_error!( + "DoublyQualified should be resolved to a Column before resolving table mask" + ); + } + Expr::Exists(_) => { + todo!(); + } + Expr::FunctionCall { + args, + order_by, + filter_over: _, + .. + } => { + if let Some(args) = args { + for arg in args.iter() { + mask |= table_mask_from_expr(arg)?; + } + } + if let Some(order_by) = order_by { + for term in order_by.iter() { + mask |= table_mask_from_expr(&term.expr)?; + } + } + } + Expr::FunctionCallStar { .. } => {} + Expr::Id(_) => panic!("Id should be resolved to a Column before resolving table mask"), + Expr::InList { lhs, not: _, rhs } => { + mask |= table_mask_from_expr(lhs)?; + if let Some(rhs) = rhs { + for rhs_expr in rhs.iter() { + mask |= table_mask_from_expr(rhs_expr)?; + } + } + } + Expr::InSelect { .. } => todo!(), + Expr::InTable { + lhs, + not: _, + rhs: _, + args, + } => { + mask |= table_mask_from_expr(lhs)?; + if let Some(args) = args { + for arg in args.iter() { + mask |= table_mask_from_expr(arg)?; + } + } + } + Expr::IsNull(expr) => { + mask |= table_mask_from_expr(expr)?; + } + Expr::Like { + lhs, + not: _, + op: _, + rhs, + escape, + } => { + mask |= table_mask_from_expr(lhs)?; + mask |= table_mask_from_expr(rhs)?; + if let Some(escape) = escape { + mask |= table_mask_from_expr(escape)?; + } + } + Expr::Literal(_) => {} + Expr::Name(_) => {} + Expr::NotNull(expr) => { + mask |= table_mask_from_expr(expr)?; + } + Expr::Parenthesized(exprs) => { + for expr in exprs.iter() { + mask |= table_mask_from_expr(expr)?; + } + } + Expr::Qualified(_, _) => { + panic!("Qualified should be resolved to a Column before resolving table mask"); + } + Expr::Raise(_, _) => todo!(), + Expr::Subquery(_) => todo!(), + Expr::Unary(_, expr) => { + mask |= table_mask_from_expr(expr)?; + } + Expr::Variable(_) => {} + } + + Ok(mask) +} + pub fn determine_where_to_eval_expr<'a>( expr: &'a Expr, join_order: &[JoinOrderMember], From de9e8442e877d19b2a6370f9bc3c57d4731df80f Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Fri, 9 May 2025 23:42:11 +0300 Subject: [PATCH 09/42] fix ephemeral --- core/translate/optimizer.rs | 651 ++++++++++++++++-------------------- 1 file changed, 284 insertions(+), 367 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 754bdeaca..9965ebae6 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -4,7 +4,7 @@ use limbo_sqlite3_parser::ast::{self, Expr, SortOrder}; use crate::{ parameters::PARAM_PREFIX, - schema::{Index, IndexColumn, Schema, Table}, + schema::{Index, IndexColumn, Schema}, translate::plan::TerminationKey, types::SeekOp, util::exprs_are_equivalent, @@ -237,7 +237,12 @@ impl<'a> AccessMethod<'a> { } } - pub fn set_constraints(&mut self, index: Option>, constraints: &'a [Constraint]) { + pub fn set_constraints(&mut self, lookup: &ConstraintLookup, constraints: &'a [Constraint]) { + let index = match lookup { + ConstraintLookup::Index(index) => Some(index), + ConstraintLookup::Rowid => None, + ConstraintLookup::EphemeralIndex => panic!("set_constraints called with Lookup::None"), + }; match (&mut self.kind, constraints.is_empty()) { ( AccessMethodKind::Search { @@ -248,23 +253,23 @@ impl<'a> AccessMethod<'a> { false, ) => { *constraints = constraints; - *i = index; + *i = index.cloned(); } (AccessMethodKind::Search { iter_dir, .. }, true) => { self.kind = AccessMethodKind::Scan { - index, + index: index.cloned(), iter_dir: *iter_dir, }; } (AccessMethodKind::Scan { iter_dir, .. }, false) => { self.kind = AccessMethodKind::Search { - index, + index: index.cloned(), iter_dir: *iter_dir, constraints, }; } (AccessMethodKind::Scan { index: i, .. }, true) => { - *i = index; + *i = index.cloned(); } } } @@ -983,13 +988,25 @@ fn use_indexes( let (best_access_methods, best_table_numbers) = (best_plan.best_access_methods, best_plan.table_numbers); + + let best_join_order: Vec = best_table_numbers + .into_iter() + .map(|table_number| JoinOrderMember { + table_no: table_number, + is_outer: table_references[table_number] + .join_info + .as_ref() + .map_or(false, |join_info| join_info.outer), + }) + .collect(); let mut to_remove_from_where_clause = vec![]; - for (i, table_number) in best_table_numbers.iter().enumerate() { + for (i, join_order_member) in best_join_order.iter().enumerate() { + let table_number = join_order_member.table_no; let access_method_kind = access_methods_arena.borrow()[best_access_methods[i]] .kind .clone(); if matches!( - table_references[*table_number].op, + table_references[table_number].op, Operation::Subquery { .. } ) { // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. @@ -997,12 +1014,49 @@ fn use_indexes( matches!(access_method_kind, AccessMethodKind::Scan { index: None, .. }), "nothing in the current optimizer should be able to optimize subqueries, but got {:?} for table {}", access_method_kind, - table_references[*table_number].table.get_name() + table_references[table_number].table.get_name() ); continue; } - table_references[*table_number].op = match access_method_kind { - AccessMethodKind::Scan { iter_dir, index } => Operation::Scan { iter_dir, index }, + table_references[table_number].op = match access_method_kind { + AccessMethodKind::Scan { iter_dir, index } => { + if index.is_some() || i == 0 { + Operation::Scan { iter_dir, index } + } else { + // Try to construct ephemeral index since it's going to be better than a scan for non-outermost tables. + let unindexable_constraints = constraints.iter().find(|c| { + c.table_no == table_number + && matches!(c.lookup, ConstraintLookup::EphemeralIndex) + }); + if let Some(unindexable) = unindexable_constraints { + let usable_constraints = usable_constraints_for_join_order( + &unindexable.constraints, + table_number, + &best_join_order[..=i], + ); + if usable_constraints.is_empty() { + Operation::Scan { iter_dir, index } + } else { + let ephemeral_index = ephemeral_index_build( + &table_references[table_number], + table_number, + &usable_constraints, + ); + let ephemeral_index = Arc::new(ephemeral_index); + Operation::Search(Search::Seek { + index: Some(ephemeral_index), + seek_def: build_seek_def_from_constraints( + usable_constraints, + iter_dir, + where_clause, + )?, + }) + } + } else { + Operation::Scan { iter_dir, index } + } + } + } AccessMethodKind::Search { index, constraints, @@ -1010,7 +1064,7 @@ fn use_indexes( } => { assert!(!constraints.is_empty()); for constraint in constraints.iter() { - to_remove_from_where_clause.push(constraint.where_clause_position.0); + to_remove_from_where_clause.push(constraint.where_clause_pos.0); } if let Some(index) = index { Operation::Search(Search::Seek { @@ -1030,7 +1084,7 @@ fn use_indexes( match constraints[0].operator { ast::Operator::Equals => Operation::Search(Search::RowidEq { cmp_expr: { - let (idx, side) = constraints[0].where_clause_position; + let (idx, side) = constraints[0].where_clause_pos; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(&where_clause[idx].expr)? else { @@ -1063,16 +1117,7 @@ fn use_indexes( for position in to_remove_from_where_clause.iter().rev() { where_clause.remove(*position); } - let best_join_order = best_table_numbers - .into_iter() - .map(|table_number| JoinOrderMember { - table_no: table_number, - is_outer: table_references[table_number] - .join_info - .as_ref() - .map_or(false, |join_info| join_info.outer), - }) - .collect(); + Ok(Some(best_join_order)) } @@ -1679,17 +1724,18 @@ pub fn find_best_access_method_for_join_order<'a>( .iter() .filter(|csmap| csmap.table_no == table_index) { - let index_info = match csmap.index.as_ref() { - Some(index) => IndexInfo { + let index_info = match &csmap.lookup { + ConstraintLookup::Index(index) => IndexInfo { unique: index.unique, covering: table_reference.index_is_covering(index), column_count: index.columns.len(), }, - None => IndexInfo { + ConstraintLookup::Rowid => IndexInfo { unique: true, // rowids are always unique covering: false, column_count: 1, }, + ConstraintLookup::EphemeralIndex => continue, }; let usable_constraints = usable_constraints_for_join_order(&csmap.constraints, table_index, join_order); @@ -1706,11 +1752,14 @@ pub fn find_best_access_method_for_join_order<'a>( for i in 0..order_target.0.len().min(index_info.column_count) { let correct_table = order_target.0[i].table_no == table_index; let correct_column = { - match csmap.index.as_ref() { - Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, - None => { + match &csmap.lookup { + ConstraintLookup::Index(index) => { + index.columns[i].pos_in_table == order_target.0[i].column_no + } + ConstraintLookup::Rowid => { rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) } + ConstraintLookup::EphemeralIndex => unreachable!(), } }; if !correct_table || !correct_column { @@ -1719,9 +1768,12 @@ pub fn find_best_access_method_for_join_order<'a>( break; } let correct_order = { - match csmap.index.as_ref() { - Some(index) => order_target.0[i].order == index.columns[i].order, - None => order_target.0[i].order == SortOrder::Asc, + match &csmap.lookup { + ConstraintLookup::Index(index) => { + order_target.0[i].order == index.columns[i].order + } + ConstraintLookup::Rowid => order_target.0[i].order == SortOrder::Asc, + ConstraintLookup::EphemeralIndex => unreachable!(), } }; if correct_order { @@ -1740,33 +1792,10 @@ pub fn find_best_access_method_for_join_order<'a>( }; if cost.total() < best_access_method.cost.total() + order_satisfiability_bonus { best_access_method.cost = cost; - best_access_method.set_constraints(csmap.index.clone(), &usable_constraints); + best_access_method.set_constraints(&csmap.lookup, &usable_constraints); } } - // FIXME: ephemeral indexes are disabled for now. - // These constraints need to be computed ad hoc. - // // We haven't found a persistent btree index that is any better than a full table scan; - // // let's see if building an ephemeral index would be better. - // if best_index.index.is_none() && matches!(table_reference.table, Table::BTree(_)) { - // let (ephemeral_cost, constraints_with_col_idx) = ephemeral_index_estimate_cost( - // where_clause, - // table_reference, - // loop_index, - // table_index, - // join_order, - // input_cardinality, - // )?; - // if ephemeral_cost.total() < best_index.cost.total() { - // // ephemeral index makes sense, so let's build it now. - // // ephemeral columns are: columns from the table_reference, constraints first, then the rest - // let ephemeral_index = - // ephemeral_index_build(table_reference, table_index, &constraints_with_col_idx); - // best_index.index = Some(Arc::new(ephemeral_index)); - // best_index.cost = ephemeral_cost; - // } - // } - let iter_dir = if let Some(order_target) = maybe_order_target { // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards let index = match &best_access_method.kind { @@ -1813,114 +1842,10 @@ pub fn find_best_access_method_for_join_order<'a>( Ok(best_access_method) } -// TODO get rid of doing this every time -fn ephemeral_index_estimate_cost( - where_clause: &[WhereTerm], - table_reference: &TableReference, - loop_index: usize, - table_index: usize, - join_order: &[JoinOrderMember], - input_cardinality: f64, -) -> Result<(ScanCost, Vec<(usize, Constraint)>)> { - let mut constraints_with_col_idx: Vec<(usize, Constraint)> = where_clause - .iter() - .enumerate() - .filter(|(_, term)| is_potential_index_constraint(term, loop_index, join_order)) - .filter_map(|(i, term)| { - let Ok(ast::Expr::Binary(lhs, operator, rhs)) = unwrap_parens(&term.expr) else { - panic!("expected binary expression"); - }; - if let ast::Expr::Column { table, column, .. } = lhs.as_ref() { - if *table == table_index { - let Ok(constraining_table_mask) = table_mask_from_expr(rhs) else { - return None; - }; - return Some(( - *column, - Constraint { - where_clause_position: (i, BinaryExprSide::Rhs), - operator: *operator, - key_position: *column, - sort_order: SortOrder::Asc, - lhs_mask: constraining_table_mask, - }, - )); - } - } - if let ast::Expr::Column { table, column, .. } = rhs.as_ref() { - if *table == table_index { - let Ok(constraining_table_mask) = table_mask_from_expr(lhs) else { - return None; - }; - return Some(( - *column, - Constraint { - where_clause_position: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), - key_position: *column, - sort_order: SortOrder::Asc, - lhs_mask: constraining_table_mask, - }, - )); - } - } - None - }) - .collect(); - // sort equalities first - constraints_with_col_idx.sort_by(|a, _| { - if a.1.operator == ast::Operator::Equals { - Ordering::Less - } else { - Ordering::Equal - } - }); - // drop everything after the first inequality - constraints_with_col_idx.truncate( - constraints_with_col_idx - .iter() - .position(|c| c.1.operator != ast::Operator::Equals) - .unwrap_or(constraints_with_col_idx.len()), - ); - if constraints_with_col_idx.is_empty() { - return Ok(( - ScanCost { - run_cost: Cost(0.0), - build_cost: Cost(f64::MAX), - }, - vec![], - )); - } - - let ephemeral_column_count = table_reference - .columns() - .iter() - .enumerate() - .filter(|(i, _)| table_reference.column_is_used(*i)) - .count(); - - let constraints_without_col_idx = constraints_with_col_idx - .iter() - .cloned() - .map(|(_, c)| c) - .collect::>(); - let ephemeral_cost = estimate_cost_for_scan_or_seek( - Some(IndexInfo { - unique: false, - column_count: ephemeral_column_count, - covering: false, - }), - &constraints_without_col_idx, - true, - input_cardinality, - ); - Ok((ephemeral_cost, constraints_with_col_idx)) -} - fn ephemeral_index_build( table_reference: &TableReference, table_index: usize, - constraints: &[(usize, Constraint)], + constraints: &[Constraint], ) -> Index { let mut ephemeral_columns: Vec = table_reference .columns() @@ -1939,11 +1864,11 @@ fn ephemeral_index_build( let a_constraint = constraints .iter() .enumerate() - .find(|(_, c)| c.0 == a.pos_in_table); + .find(|(_, c)| c.table_col_pos == a.pos_in_table); let b_constraint = constraints .iter() .enumerate() - .find(|(_, c)| c.0 == b.pos_in_table); + .find(|(_, c)| c.table_col_pos == b.pos_in_table); match (a_constraint, b_constraint) { (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, @@ -1971,12 +1896,14 @@ fn ephemeral_index_build( pub struct Constraint { /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), /// since the RHS '10' is the constraining expression and it's part of the second term in the WHERE clause. - where_clause_position: (usize, BinaryExprSide), + where_clause_pos: (usize, BinaryExprSide), /// The operator of the constraint, e.g. =, >, < operator: ast::Operator, /// The position of the index column in the index, e.g. if the index is (a,b,c) and the constraint is on b, then index_column_pos is 1. /// For Rowid constraints this is always 0. - key_position: usize, + index_col_pos: usize, + /// The position of the constrained column in the table. + table_col_pos: usize, /// The sort order of the index column, ASC or DESC. For Rowid constraints this is always ASC. sort_order: SortOrder, /// Bitmask of tables that are required to be on the left side of the constrained table, @@ -1984,15 +1911,28 @@ pub struct Constraint { lhs_mask: TableMask, } +#[derive(Debug, Clone)] +/// Lookup denotes how a given set of [Constraint]s can be used to access a table. +/// +/// Lookup::Index(index) means that the constraints can be used to access the table using the given index. +/// Lookup::Rowid means that the constraints can be used to access the table using the table's rowid column. +/// Lookup::EphemeralIndex means that the constraints are not useful for accessing the table, +/// but an ephemeral index can be built ad-hoc to use them. +pub enum ConstraintLookup { + Index(Arc), + Rowid, + EphemeralIndex, +} + #[derive(Debug)] /// A collection of [Constraint]s for a given (table, index) pair. pub struct Constraints { - index: Option>, + lookup: ConstraintLookup, table_no: usize, constraints: Vec, } -/// Precompute all potentially usable constraints from a WHERE clause. +/// Precompute all potentially usable [Constraints] from a WHERE clause. /// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. pub fn constraints_from_where_clause( where_clause: &[WhereTerm], @@ -2006,131 +1946,173 @@ pub fn constraints_from_where_clause( .iter() .position(|c| c.is_rowid_alias); - if let Some(rowid_alias_column) = rowid_alias_column { - let mut cs = Constraints { - index: None, - table_no, - constraints: Vec::new(), + let mut cs = Constraints { + lookup: ConstraintLookup::Rowid, + table_no, + constraints: Vec::new(), + }; + let mut cs_ephemeral = Constraints { + lookup: ConstraintLookup::EphemeralIndex, + table_no, + constraints: Vec::new(), + }; + for (i, term) in where_clause.iter().enumerate() { + let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { + continue; }; - for (i, term) in where_clause.iter().enumerate() { - let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { - continue; - }; - if !matches!( - operator, - ast::Operator::Equals - | ast::Operator::Greater - | ast::Operator::Less - | ast::Operator::GreaterEquals - | ast::Operator::LessEquals - ) { - continue; - } - if let Some(outer_join_tbl) = term.from_outer_join { - if outer_join_tbl != table_no { - continue; - } - } - match lhs.as_ref() { - ast::Expr::Column { table, column, .. } => { - if *table == table_no && *column == rowid_alias_column { - cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Rhs), - operator: *operator, - key_position: 0, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - } - ast::Expr::RowId { table, .. } => { - if *table == table_no { - cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Rhs), - operator: *operator, - key_position: 0, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - } - _ => {} - }; - match rhs.as_ref() { - ast::Expr::Column { table, column, .. } => { - if *table == table_no && *column == rowid_alias_column { - cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), - key_position: 0, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - ast::Expr::RowId { table, .. } => { - if *table == table_no { - cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), - key_position: 0, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - _ => {} - }; + if !matches!( + operator, + ast::Operator::Equals + | ast::Operator::Greater + | ast::Operator::Less + | ast::Operator::GreaterEquals + | ast::Operator::LessEquals + ) { + continue; } - // First sort by position, with equalities first within each position - cs.constraints.sort_by(|a, b| { - let pos_cmp = a.key_position.cmp(&b.key_position); - if pos_cmp == Ordering::Equal { - // If same position, sort equalities first - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater - } else { - Ordering::Equal + if let Some(outer_join_tbl) = term.from_outer_join { + if outer_join_tbl != table_no { + continue; + } + } + match lhs.as_ref() { + ast::Expr::Column { table, column, .. } => { + if *table == table_no { + if rowid_alias_column.map_or(false, |idx| *column == idx) { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator: *operator, + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } else { + cs_ephemeral.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator: *operator, + index_col_pos: 0, + table_col_pos: *column, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no && rowid_alias_column.is_some() { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator: *operator, + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + } + _ => {} + }; + match rhs.as_ref() { + ast::Expr::Column { table, column, .. } => { + if *table == table_no { + if rowid_alias_column.map_or(false, |idx| *column == idx) { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } else { + cs_ephemeral.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + index_col_pos: 0, + table_col_pos: *column, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no && rowid_alias_column.is_some() { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(*operator), + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + _ => {} + }; + } + // First sort by position, with equalities first within each position + cs.constraints.sort_by(|a, b| { + let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); + if pos_cmp == Ordering::Equal { + // If same position, sort equalities first + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater } else { - pos_cmp + Ordering::Equal } - }); + } else { + pos_cmp + } + }); + cs_ephemeral.constraints.sort_by(|a, b| { + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + }); - // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.key_position); + // Deduplicate by position, keeping first occurrence (which will be equality if one exists) + cs.constraints.dedup_by_key(|c| c.index_col_pos); - // Truncate at first gap in positions - let mut last_pos = 0; - let mut i = 0; - for constraint in cs.constraints.iter() { - if constraint.key_position != last_pos { - if constraint.key_position != last_pos + 1 { - break; - } - last_pos = constraint.key_position; + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for constraint in cs.constraints.iter() { + if constraint.index_col_pos != last_pos { + if constraint.index_col_pos != last_pos + 1 { + break; } - i += 1; + last_pos = constraint.index_col_pos; } - cs.constraints.truncate(i); + i += 1; + } + cs.constraints.truncate(i); - // Truncate after the first inequality - if let Some(first_inequality) = cs - .constraints - .iter() - .position(|c| c.operator != ast::Operator::Equals) - { - cs.constraints.truncate(first_inequality + 1); - } + // Truncate after the first inequality + if let Some(first_inequality) = cs + .constraints + .iter() + .position(|c| c.operator != ast::Operator::Equals) + { + cs.constraints.truncate(first_inequality + 1); + } + if rowid_alias_column.is_some() { constraints.push(cs); } + constraints.push(cs_ephemeral); + let indexes = available_indexes.get(table_reference.table.get_name()); if let Some(indexes) = indexes { for index in indexes { let mut cs = Constraints { - index: Some(index.clone()), + lookup: ConstraintLookup::Index(index.clone()), table_no, constraints: Vec::new(), }; @@ -2157,9 +2139,15 @@ pub fn constraints_from_where_clause( get_column_position_in_index(lhs, table_no, index)? { cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Rhs), + where_clause_pos: (i, BinaryExprSide::Rhs), operator: *operator, - key_position: position_in_index, + index_col_pos: position_in_index, + table_col_pos: { + let ast::Expr::Column { column, .. } = lhs.as_ref() else { + crate::bail_parse_error!("expected column in index constraint"); + }; + *column + }, sort_order: index.columns[position_in_index].order, lhs_mask: table_mask_from_expr(rhs)?, }); @@ -2168,9 +2156,15 @@ pub fn constraints_from_where_clause( get_column_position_in_index(rhs, table_no, index)? { cs.constraints.push(Constraint { - where_clause_position: (i, BinaryExprSide::Lhs), + where_clause_pos: (i, BinaryExprSide::Lhs), operator: opposite_cmp_op(*operator), - key_position: position_in_index, + index_col_pos: position_in_index, + table_col_pos: { + let ast::Expr::Column { column, .. } = rhs.as_ref() else { + crate::bail_parse_error!("expected column in index constraint"); + }; + *column + }, sort_order: index.columns[position_in_index].order, lhs_mask: table_mask_from_expr(lhs)?, }); @@ -2178,7 +2172,7 @@ pub fn constraints_from_where_clause( } // First sort by position, with equalities first within each position cs.constraints.sort_by(|a, b| { - let pos_cmp = a.key_position.cmp(&b.key_position); + let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); if pos_cmp == Ordering::Equal { // If same position, sort equalities first if a.operator == ast::Operator::Equals { @@ -2194,17 +2188,17 @@ pub fn constraints_from_where_clause( }); // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.key_position); + cs.constraints.dedup_by_key(|c| c.index_col_pos); // Truncate at first gap in positions let mut last_pos = 0; let mut i = 0; for constraint in cs.constraints.iter() { - if constraint.key_position != last_pos { - if constraint.key_position != last_pos + 1 { + if constraint.index_col_pos != last_pos { + if constraint.index_col_pos != last_pos + 1 { break; } - last_pos = constraint.key_position; + last_pos = constraint.index_col_pos; } i += 1; } @@ -2222,6 +2216,7 @@ pub fn constraints_from_where_clause( } } } + Ok(constraints) } @@ -2350,7 +2345,7 @@ pub fn build_seek_def_from_constraints( for constraint in constraints { // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) - let (idx, side) = constraint.where_clause_position; + let (idx, side) = constraint.where_clause_pos; let where_term = &where_clause[idx]; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { crate::bail_parse_error!("expected binary expression"); @@ -3051,7 +3046,7 @@ mod tests { iter_dir, constraints, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs), + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs), ), "expected rowid eq access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind @@ -3113,7 +3108,7 @@ mod tests { iter_dir, constraints, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs), + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" ), "expected index search access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind @@ -3194,7 +3189,7 @@ mod tests { iter_dir, constraints, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs) && index.name == "index1", + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", ), "expected Search access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind @@ -3410,84 +3405,6 @@ mod tests { } } - #[test] - /// Test that [compute_best_join_order] faces a query with no indexes, - /// it chooses the outer table based on the most restrictive filter, - /// and builds an ephemeral index on the inner table. - fn test_join_order_no_indexes_inner_ephemeral() { - let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); - let t2 = _create_btree_table("t2", _create_column_list(&["id", "foo"], Type::Integer)); - - let mut table_references = vec![ - _create_table_reference(t1.clone(), None), - _create_table_reference( - t2.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - ]; - - // SELECT * FROM t1 JOIN t2 ON t1.id = t2.id WHERE t2.foo > 10 - let where_clause = vec![ - // t2.foo > 10 - // this should make the optimizer choose t2 as the outer table despite being inner in the query, - // because it restricts the output of t2 to a smaller set of rows, resulting in a cheaper plan. - _create_binary_expr( - _create_column_expr(1, 1, false), // table 1, column 1 (foo) - ast::Operator::Greater, - _create_numeric_literal("10"), - ), - // t1.id = t2.id - // this should make the optimizer choose to create an ephemeral index on t1 - // because it is cheaper than a table scan, despite the cost of building the index. - _create_binary_expr( - _create_column_expr(0, 0, false), // table 0, column 0 (id) - ast::Operator::Equals, - _create_column_expr(1, 0, false), // table 1, column 0 (id) - ), - ]; - - let available_indexes = HashMap::new(); - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( - &mut table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap() - .unwrap(); - - // Verify that t2 is chosen first due to its filter - assert_eq!(best_plan.table_numbers[0], 1, "best_plan: {:?}", best_plan); - // Verify table scan is used since there are no indexes - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); - // Verify that an ephemeral index was built on t1 - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_position == (0, BinaryExprSide::Rhs) && index.ephemeral - ), - "expected ephemeral index, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind - ); - } - #[test] fn test_join_order_three_tables_no_indexes() { let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); From a92d94270a4b1722d7b80a0c9fdd0103f6131c1a Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Fri, 9 May 2025 23:50:38 +0300 Subject: [PATCH 10/42] Get rid of useless ScanCost struct --- core/translate/optimizer.rs | 64 ++++++++----------------------------- 1 file changed, 14 insertions(+), 50 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 9965ebae6..6f4a164ec 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -202,7 +202,7 @@ fn join_lhs_tables_to_rhs_table<'a>( )?; let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); - let cost = lhs_cost + best_access_method.cost.total(); + let cost = lhs_cost + best_access_method.cost; let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { let mut numbers = l.table_numbers.clone(); @@ -222,10 +222,11 @@ fn join_lhs_tables_to_rhs_table<'a>( } #[derive(Debug, Clone)] +/// Represents a way to access a table. pub struct AccessMethod<'a> { - // The estimated number of page fetches. - // We are ignoring CPU cost for now. - pub cost: ScanCost, + /// The estimated number of page fetches. + /// We are ignoring CPU cost for now. + pub cost: Cost, pub kind: AccessMethodKind<'a>, } @@ -276,6 +277,7 @@ impl<'a> AccessMethod<'a> { } #[derive(Debug, Clone)] +/// Represents the kind of access method. pub enum AccessMethodKind<'a> { /// A full scan, which can be an index scan or a table scan. Scan { @@ -1564,29 +1566,6 @@ struct IndexInfo { covering: bool, } -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ScanCost { - run_cost: Cost, - build_cost: Cost, -} - -impl std::ops::Add for ScanCost { - type Output = ScanCost; - - fn add(self, other: ScanCost) -> ScanCost { - ScanCost { - run_cost: self.run_cost + other.run_cost, - build_cost: self.build_cost + other.build_cost, - } - } -} - -impl ScanCost { - pub fn total(&self) -> Cost { - self.run_cost + self.build_cost - } -} - const ESTIMATED_HARDCODED_ROWS_PER_TABLE: usize = 1000000; const ESTIMATED_HARDCODED_ROWS_PER_PAGE: usize = 50; // roughly 80 bytes per 4096 byte page @@ -1601,21 +1580,12 @@ fn estimate_page_io_cost(rowcount: f64) -> Cost { fn estimate_cost_for_scan_or_seek( index_info: Option, constraints: &[Constraint], - is_ephemeral: bool, input_cardinality: f64, -) -> ScanCost { - let build_cost = if is_ephemeral { - estimate_page_io_cost(2.0 * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64) - } else { - Cost(0.0) - }; +) -> Cost { let Some(index_info) = index_info else { - return ScanCost { - run_cost: estimate_page_io_cost( - input_cardinality * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, - ), - build_cost, - }; + return estimate_page_io_cost( + input_cardinality * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, + ); }; let final_constraint_is_range = constraints @@ -1659,16 +1629,12 @@ fn estimate_cost_for_scan_or_seek( // little bonus for covering indexes let covering_multiplier = if index_info.covering { 0.9 } else { 1.0 }; - let cost = estimate_page_io_cost( + estimate_page_io_cost( cost_multiplier * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 * input_cardinality * covering_multiplier, - ); - ScanCost { - run_cost: cost, - build_cost, - } + ) } fn usable_constraints_for_join_order<'a>( @@ -1707,8 +1673,7 @@ pub fn find_best_access_method_for_join_order<'a>( maybe_order_target: Option<&OrderTarget>, input_cardinality: f64, ) -> Result> { - let cost_of_full_table_scan = - estimate_cost_for_scan_or_seek(None, &[], false, input_cardinality); + let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], input_cardinality); let mut best_access_method = AccessMethod { cost: cost_of_full_table_scan, kind: AccessMethodKind::Scan { @@ -1742,7 +1707,6 @@ pub fn find_best_access_method_for_join_order<'a>( let cost = estimate_cost_for_scan_or_seek( Some(index_info), &usable_constraints, - false, input_cardinality, ); @@ -1790,7 +1754,7 @@ pub fn find_best_access_method_for_join_order<'a>( } else { Cost(0.0) }; - if cost.total() < best_access_method.cost.total() + order_satisfiability_bonus { + if cost < best_access_method.cost + order_satisfiability_bonus { best_access_method.cost = cost; best_access_method.set_constraints(&csmap.lookup, &usable_constraints); } From 5f9ebe26a0365901c313acbbe0777a4d34513799 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:00:40 +0300 Subject: [PATCH 11/42] as_binary_components() helper --- core/translate/optimizer.rs | 81 ++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 46 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 6f4a164ec..3a16098d0 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -1896,6 +1896,26 @@ pub struct Constraints { constraints: Vec, } +fn as_binary_components( + expr: &ast::Expr, +) -> Result> { + match unwrap_parens(expr)? { + ast::Expr::Binary(lhs, operator, rhs) + if matches!( + operator, + ast::Operator::Equals + | ast::Operator::Greater + | ast::Operator::Less + | ast::Operator::GreaterEquals + | ast::Operator::LessEquals + ) => + { + Ok(Some((lhs.as_ref(), *operator, rhs.as_ref()))) + } + _ => Ok(None), + } +} + /// Precompute all potentially usable [Constraints] from a WHERE clause. /// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. pub fn constraints_from_where_clause( @@ -1921,31 +1941,21 @@ pub fn constraints_from_where_clause( constraints: Vec::new(), }; for (i, term) in where_clause.iter().enumerate() { - let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { + let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { continue; }; - if !matches!( - operator, - ast::Operator::Equals - | ast::Operator::Greater - | ast::Operator::Less - | ast::Operator::GreaterEquals - | ast::Operator::LessEquals - ) { - continue; - } if let Some(outer_join_tbl) = term.from_outer_join { if outer_join_tbl != table_no { continue; } } - match lhs.as_ref() { + match lhs { ast::Expr::Column { table, column, .. } => { if *table == table_no { if rowid_alias_column.map_or(false, |idx| *column == idx) { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Rhs), - operator: *operator, + operator, index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), sort_order: SortOrder::Asc, @@ -1954,7 +1964,7 @@ pub fn constraints_from_where_clause( } else { cs_ephemeral.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Rhs), - operator: *operator, + operator, index_col_pos: 0, table_col_pos: *column, sort_order: SortOrder::Asc, @@ -1967,7 +1977,7 @@ pub fn constraints_from_where_clause( if *table == table_no && rowid_alias_column.is_some() { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Rhs), - operator: *operator, + operator, index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), sort_order: SortOrder::Asc, @@ -1977,13 +1987,13 @@ pub fn constraints_from_where_clause( } _ => {} }; - match rhs.as_ref() { + match rhs { ast::Expr::Column { table, column, .. } => { if *table == table_no { if rowid_alias_column.map_or(false, |idx| *column == idx) { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), + operator: opposite_cmp_op(operator), index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), sort_order: SortOrder::Asc, @@ -1992,7 +2002,7 @@ pub fn constraints_from_where_clause( } else { cs_ephemeral.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), + operator: opposite_cmp_op(operator), index_col_pos: 0, table_col_pos: *column, sort_order: SortOrder::Asc, @@ -2005,7 +2015,7 @@ pub fn constraints_from_where_clause( if *table == table_no && rowid_alias_column.is_some() { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), + operator: opposite_cmp_op(operator), index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), sort_order: SortOrder::Asc, @@ -2081,19 +2091,9 @@ pub fn constraints_from_where_clause( constraints: Vec::new(), }; for (i, term) in where_clause.iter().enumerate() { - let ast::Expr::Binary(lhs, operator, rhs) = unwrap_parens(&term.expr)? else { + let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { continue; }; - if !matches!( - operator, - ast::Operator::Equals - | ast::Operator::Greater - | ast::Operator::Less - | ast::Operator::GreaterEquals - | ast::Operator::LessEquals - ) { - continue; - } if let Some(outer_join_tbl) = term.from_outer_join { if outer_join_tbl != table_no { continue; @@ -2104,10 +2104,10 @@ pub fn constraints_from_where_clause( { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Rhs), - operator: *operator, + operator, index_col_pos: position_in_index, table_col_pos: { - let ast::Expr::Column { column, .. } = lhs.as_ref() else { + let ast::Expr::Column { column, .. } = lhs else { crate::bail_parse_error!("expected column in index constraint"); }; *column @@ -2121,10 +2121,10 @@ pub fn constraints_from_where_clause( { cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(*operator), + operator: opposite_cmp_op(operator), index_col_pos: position_in_index, table_col_pos: { - let ast::Expr::Column { column, .. } = rhs.as_ref() else { + let ast::Expr::Column { column, .. } = rhs else { crate::bail_parse_error!("expected column in index constraint"); }; *column @@ -2261,20 +2261,9 @@ fn is_potential_index_constraint( return false; } // Skip terms that are not binary comparisons - let Ok(ast::Expr::Binary(lhs, operator, rhs)) = unwrap_parens(&term.expr) else { + let Ok(Some((lhs, _, rhs))) = as_binary_components(&term.expr) else { return false; }; - // Only consider index scans for binary ops that are comparisons - if !matches!( - *operator, - ast::Operator::Equals - | ast::Operator::Greater - | ast::Operator::GreaterEquals - | ast::Operator::Less - | ast::Operator::LessEquals - ) { - return false; - } // If both lhs and rhs refer to columns from this table, we can't use this constraint // because we can't use the index to satisfy the condition. From 62d2ee8eb6ffa9cbe9be590b890e3dd8c9aaff76 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:11:46 +0300 Subject: [PATCH 12/42] rename --- core/translate/optimizer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 3a16098d0..dd21b7d3f 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -137,7 +137,7 @@ fn join_lhs_tables_to_rhs_table<'a>( // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. let output_cardinality_multiplier = where_clause .iter() - .filter(|term| is_potential_index_constraint(term, loop_idx, &join_order)) + .filter(|term| affects_result_set_of_table(term, loop_idx, &join_order)) .map(|term| { let ast::Expr::Binary(lhs, op, rhs) = &term.expr else { return 1.0; @@ -2251,7 +2251,7 @@ fn get_column_position_in_index( Ok(index.column_table_pos_to_index_pos(*column)) } -fn is_potential_index_constraint( +fn affects_result_set_of_table( term: &WhereTerm, loop_index: usize, join_order: &[JoinOrderMember], From 630a6093aa2ead73b50daa11ab197b31d071e164 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:25:52 +0300 Subject: [PATCH 13/42] refactor join_lhs_tables_to_rhs_table --- core/translate/optimizer.rs | 175 ++++++++++++++++++------------------ 1 file changed, 86 insertions(+), 89 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index dd21b7d3f..35b0a14f9 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -133,64 +133,10 @@ fn join_lhs_tables_to_rhs_table<'a>( maybe_order_target: Option<&OrderTarget>, access_methods_arena: &'a RefCell>>, ) -> Result { - let loop_idx = lhs.map_or(0, |l| l.table_numbers.len()); - // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. - let output_cardinality_multiplier = where_clause - .iter() - .filter(|term| affects_result_set_of_table(term, loop_idx, &join_order)) - .map(|term| { - let ast::Expr::Binary(lhs, op, rhs) = &term.expr else { - return 1.0; - }; - let mut column = if let ast::Expr::Column { table, column, .. } = lhs.as_ref() { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some(&columns[*column]) - } - } else { - None - }; - if column.is_none() { - column = if let ast::Expr::Column { table, column, .. } = rhs.as_ref() { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some(&columns[*column]) - } - } else { - None - } - }; - let Some(column) = column else { - return 1.0; - }; - match op { - ast::Operator::Equals => { - if column.is_rowid_alias || column.primary_key { - 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - } else { - SELECTIVITY_EQ - } - } - ast::Operator::Greater => SELECTIVITY_RANGE, - ast::Operator::GreaterEquals => SELECTIVITY_RANGE, - ast::Operator::Less => SELECTIVITY_RANGE, - ast::Operator::LessEquals => SELECTIVITY_RANGE, - _ => SELECTIVITY_OTHER, - } - }) - .product::(); - - // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. - // If there is an input best_plan on the left, we multiply the input cardinality by the estimated number of rows per table. + // The input cardinality for this join is the output cardinality of the previous join. + // For example, in a 2-way join, if the left table has 1000 rows, and the right table will return 2 rows for each of the left table's rows, + // then the output cardinality of the join will be 2000. let input_cardinality = lhs.map_or(1, |l| l.output_cardinality); - let output_cardinality = (input_cardinality as f64 - * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - * output_cardinality_multiplier) - .ceil() as usize; let best_access_method = find_best_access_method_for_join_order( rhs_table_number, @@ -213,6 +159,89 @@ fn join_lhs_tables_to_rhs_table<'a>( access_methods_arena.borrow_mut().push(best_access_method); let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); best_access_methods.push(access_methods_arena.borrow().len() - 1); + + // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. + let output_cardinality_multiplier = where_clause + .iter() + .filter_map(|term| { + // Skip terms that are not binary comparisons + let Ok(Some((lhs, op, rhs))) = as_binary_components(&term.expr) else { + return None; + }; + // Skip terms that cannot be evaluated at this table's loop level + if !term.should_eval_at_loop(join_order.len() - 1, join_order) { + return None; + } + + // If both lhs and rhs refer to columns from this table, we can't use this constraint + // because we can't use the index to satisfy the condition. + // Examples: + // - WHERE t.x > t.y + // - WHERE t.x + 1 > t.y - 5 + // - WHERE t.x = (t.x) + let Ok(eval_at_left) = determine_where_to_eval_expr(&lhs, join_order) else { + return None; + }; + let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { + return None; + }; + if eval_at_left == EvalAt::Loop(join_order.len() - 1) + && eval_at_right == EvalAt::Loop(join_order.len() - 1) + { + return None; + } + + Some((lhs, op, rhs)) + }) + .filter_map(|(lhs, op, rhs)| { + // Skip terms where neither lhs nor rhs refer to columns from this table + if let ast::Expr::Column { table, column, .. } = lhs { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some((&columns[*column], op)) + } + } else { + None + } + .or_else(|| { + if let ast::Expr::Column { table, column, .. } = rhs { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some((&columns[*column], op)) + } + } else { + None + } + }) + }) + .map(|(column, op)| match op { + ast::Operator::Equals => { + if column.is_rowid_alias || column.primary_key { + 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + } else { + SELECTIVITY_EQ + } + } + ast::Operator::Greater => SELECTIVITY_RANGE, + ast::Operator::GreaterEquals => SELECTIVITY_RANGE, + ast::Operator::Less => SELECTIVITY_RANGE, + ast::Operator::LessEquals => SELECTIVITY_RANGE, + _ => SELECTIVITY_OTHER, + }) + .product::(); + + // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. + // If this table is the rightmost table in the join order, we multiply by the input cardinality, + // which is the output cardinality of the previous tables. + let output_cardinality = (input_cardinality as f64 + * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * output_cardinality_multiplier) + .ceil() as usize; + Ok(JoinN { table_numbers: new_numbers, best_access_methods, @@ -2251,38 +2280,6 @@ fn get_column_position_in_index( Ok(index.column_table_pos_to_index_pos(*column)) } -fn affects_result_set_of_table( - term: &WhereTerm, - loop_index: usize, - join_order: &[JoinOrderMember], -) -> bool { - // Skip terms that cannot be evaluated at this table's loop level - if !term.should_eval_at_loop(loop_index, join_order) { - return false; - } - // Skip terms that are not binary comparisons - let Ok(Some((lhs, _, rhs))) = as_binary_components(&term.expr) else { - return false; - }; - - // If both lhs and rhs refer to columns from this table, we can't use this constraint - // because we can't use the index to satisfy the condition. - // Examples: - // - WHERE t.x > t.y - // - WHERE t.x + 1 > t.y - 5 - // - WHERE t.x = (t.x) - let Ok(eval_at_left) = determine_where_to_eval_expr(&lhs, join_order) else { - return false; - }; - let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { - return false; - }; - if eval_at_left == EvalAt::Loop(loop_index) && eval_at_right == EvalAt::Loop(loop_index) { - return false; - } - true -} - /// Build a [SeekDef] for a given list of [Constraint]s pub fn build_seek_def_from_constraints( constraints: &[Constraint], From c8f5bd3f4f45723eafbc97dff85ce696c86ffe0b Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:26:11 +0300 Subject: [PATCH 14/42] rename --- core/translate/optimizer.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 35b0a14f9..6fd0855ee 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -123,7 +123,8 @@ const SELECTIVITY_EQ: f64 = 0.01; const SELECTIVITY_RANGE: f64 = 0.4; const SELECTIVITY_OTHER: f64 = 0.9; -fn join_lhs_tables_to_rhs_table<'a>( +/// Join n-1 tables with the n'th table. +fn join_lhs_and_rhs<'a>( lhs: Option<&JoinN>, rhs_table_number: usize, rhs_table_reference: &TableReference, @@ -582,7 +583,7 @@ fn compute_best_join_order<'a>( is_outer: false, }; assert!(join_order.len() == 1); - let rel = join_lhs_tables_to_rhs_table( + let rel = join_lhs_and_rhs( None, i, table_ref, @@ -698,7 +699,7 @@ fn compute_best_join_order<'a>( assert!(join_order.len() == subset_size); // Calculate the best way to join LHS with RHS. - let rel = join_lhs_tables_to_rhs_table( + let rel = join_lhs_and_rhs( Some(lhs), rhs_idx, &table_references[rhs_idx], @@ -803,7 +804,7 @@ fn compute_naive_left_deep_plan<'a>( .collect::>(); // Start with first table - let mut best_plan = join_lhs_tables_to_rhs_table( + let mut best_plan = join_lhs_and_rhs( None, 0, &table_references[0], @@ -816,7 +817,7 @@ fn compute_naive_left_deep_plan<'a>( // Add remaining tables one at a time from left to right for i in 1..n { - best_plan = join_lhs_tables_to_rhs_table( + best_plan = join_lhs_and_rhs( Some(&best_plan), i, &table_references[i], From 90de8791f5ca38184dc123ae596fe71633799438 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:27:48 +0300 Subject: [PATCH 15/42] comments --- core/translate/optimizer.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 6fd0855ee..21105b7ec 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -119,8 +119,11 @@ struct JoinN { pub cost: Cost, } +/// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. const SELECTIVITY_EQ: f64 = 0.01; +/// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. const SELECTIVITY_RANGE: f64 = 0.4; +/// In lieu of statistics, we estimate that other filters will reduce the output set to 90% of its size. const SELECTIVITY_OTHER: f64 = 0.9; /// Join n-1 tables with the n'th table. From c639a4367620cc2686f27479ad8fc24fab74e63a Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:41:27 +0300 Subject: [PATCH 16/42] fix parenthesized column edge case --- core/translate/optimizer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/translate/optimizer.rs b/core/translate/optimizer.rs index 21105b7ec..c08e4eb3d 100644 --- a/core/translate/optimizer.rs +++ b/core/translate/optimizer.rs @@ -2140,7 +2140,7 @@ pub fn constraints_from_where_clause( operator, index_col_pos: position_in_index, table_col_pos: { - let ast::Expr::Column { column, .. } = lhs else { + let ast::Expr::Column { column, .. } = unwrap_parens(lhs)? else { crate::bail_parse_error!("expected column in index constraint"); }; *column @@ -2157,7 +2157,7 @@ pub fn constraints_from_where_clause( operator: opposite_cmp_op(operator), index_col_pos: position_in_index, table_col_pos: { - let ast::Expr::Column { column, .. } = rhs else { + let ast::Expr::Column { column, .. } = unwrap_parens(rhs)? else { crate::bail_parse_error!("expected column in index constraint"); }; *column From ec45a92baca79842c3f4e7c662da3fd11883a1f1 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 00:47:06 +0300 Subject: [PATCH 17/42] move optimizer to its own directory --- core/translate/{ => optimizer}/OPTIMIZER.md | 0 core/translate/{optimizer.rs => optimizer/mod.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename core/translate/{ => optimizer}/OPTIMIZER.md (100%) rename core/translate/{optimizer.rs => optimizer/mod.rs} (100%) diff --git a/core/translate/OPTIMIZER.md b/core/translate/optimizer/OPTIMIZER.md similarity index 100% rename from core/translate/OPTIMIZER.md rename to core/translate/optimizer/OPTIMIZER.md diff --git a/core/translate/optimizer.rs b/core/translate/optimizer/mod.rs similarity index 100% rename from core/translate/optimizer.rs rename to core/translate/optimizer/mod.rs From bd875e387666fba655c5e59bc894c7447db88b3e Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 01:18:35 +0300 Subject: [PATCH 18/42] optimizer module split --- core/translate/expr.rs | 61 + core/translate/optimizer/access_method.rs | 229 ++ core/translate/optimizer/constraints.rs | 382 ++++ core/translate/optimizer/cost.rs | 103 + core/translate/optimizer/join.rs | 1412 ++++++++++++ core/translate/optimizer/mod.rs | 2394 +-------------------- core/translate/optimizer/order.rs | 254 +++ 7 files changed, 2458 insertions(+), 2377 deletions(-) create mode 100644 core/translate/optimizer/access_method.rs create mode 100644 core/translate/optimizer/constraints.rs create mode 100644 core/translate/optimizer/cost.rs create mode 100644 core/translate/optimizer/join.rs create mode 100644 core/translate/optimizer/order.rs diff --git a/core/translate/expr.rs b/core/translate/expr.rs index 0adaee185..50e2db073 100644 --- a/core/translate/expr.rs +++ b/core/translate/expr.rs @@ -2579,3 +2579,64 @@ pub fn maybe_apply_affinity(col_type: Type, target_register: usize, program: &mu pub fn sanitize_string(input: &str) -> String { input[1..input.len() - 1].replace("''", "'").to_string() } + +pub fn as_binary_components( + expr: &ast::Expr, +) -> Result> { + match unwrap_parens(expr)? { + ast::Expr::Binary(lhs, operator, rhs) + if matches!( + operator, + ast::Operator::Equals + | ast::Operator::Greater + | ast::Operator::Less + | ast::Operator::GreaterEquals + | ast::Operator::LessEquals + ) => + { + Ok(Some((lhs.as_ref(), *operator, rhs.as_ref()))) + } + _ => Ok(None), + } +} + +/// Recursively unwrap parentheses from an expression +/// e.g. (((t.x > 5))) -> t.x > 5 +pub fn unwrap_parens(expr: T) -> Result +where + T: UnwrapParens, +{ + expr.unwrap_parens() +} + +pub trait UnwrapParens { + fn unwrap_parens(self) -> Result + where + Self: Sized; +} + +impl UnwrapParens for &ast::Expr { + fn unwrap_parens(self) -> Result { + match self { + ast::Expr::Column { .. } => Ok(self), + ast::Expr::Parenthesized(exprs) => match exprs.len() { + 1 => unwrap_parens(exprs.first().unwrap()), + _ => crate::bail_parse_error!("expected single expression in parentheses"), + }, + _ => Ok(self), + } + } +} + +impl UnwrapParens for ast::Expr { + fn unwrap_parens(self) -> Result { + match self { + ast::Expr::Column { .. } => Ok(self), + ast::Expr::Parenthesized(mut exprs) => match exprs.len() { + 1 => unwrap_parens(exprs.pop().unwrap()), + _ => crate::bail_parse_error!("expected single expression in parentheses"), + }, + _ => Ok(self), + } + } +} diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs new file mode 100644 index 000000000..b706d074a --- /dev/null +++ b/core/translate/optimizer/access_method.rs @@ -0,0 +1,229 @@ +use std::sync::Arc; + +use limbo_sqlite3_parser::ast::SortOrder; + +use crate::{ + schema::Index, + translate::plan::{IterationDirection, JoinOrderMember, TableReference}, + Result, +}; + +use super::{ + constraints::{usable_constraints_for_join_order, Constraint, ConstraintLookup, Constraints}, + cost::{estimate_cost_for_scan_or_seek, Cost, IndexInfo}, + order::OrderTarget, +}; + +#[derive(Debug, Clone)] +/// Represents a way to access a table. +pub struct AccessMethod<'a> { + /// The estimated number of page fetches. + /// We are ignoring CPU cost for now. + pub cost: Cost, + pub kind: AccessMethodKind<'a>, +} + +impl<'a> AccessMethod<'a> { + pub fn set_iter_dir(&mut self, new_dir: IterationDirection) { + match &mut self.kind { + AccessMethodKind::Scan { iter_dir, .. } => *iter_dir = new_dir, + AccessMethodKind::Search { iter_dir, .. } => *iter_dir = new_dir, + } + } + + pub fn set_constraints(&mut self, lookup: &ConstraintLookup, constraints: &'a [Constraint]) { + let index = match lookup { + ConstraintLookup::Index(index) => Some(index), + ConstraintLookup::Rowid => None, + ConstraintLookup::EphemeralIndex => panic!("set_constraints called with Lookup::None"), + }; + match (&mut self.kind, constraints.is_empty()) { + ( + AccessMethodKind::Search { + constraints, + index: i, + .. + }, + false, + ) => { + *constraints = constraints; + *i = index.cloned(); + } + (AccessMethodKind::Search { iter_dir, .. }, true) => { + self.kind = AccessMethodKind::Scan { + index: index.cloned(), + iter_dir: *iter_dir, + }; + } + (AccessMethodKind::Scan { iter_dir, .. }, false) => { + self.kind = AccessMethodKind::Search { + index: index.cloned(), + iter_dir: *iter_dir, + constraints, + }; + } + (AccessMethodKind::Scan { index: i, .. }, true) => { + *i = index.cloned(); + } + } + } +} + +#[derive(Debug, Clone)] +/// Represents the kind of access method. +pub enum AccessMethodKind<'a> { + /// A full scan, which can be an index scan or a table scan. + Scan { + index: Option>, + iter_dir: IterationDirection, + }, + /// A search, which can be an index seek or a rowid-based search. + Search { + index: Option>, + iter_dir: IterationDirection, + constraints: &'a [Constraint], + }, +} + +/// Return the best [AccessMethod] for a given join order. +/// table_index and table_reference refer to the rightmost table in the join order. +pub fn find_best_access_method_for_join_order<'a>( + table_index: usize, + table_reference: &TableReference, + constraints: &'a [Constraints], + join_order: &[JoinOrderMember], + maybe_order_target: Option<&OrderTarget>, + input_cardinality: f64, +) -> Result> { + let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], input_cardinality); + let mut best_access_method = AccessMethod { + cost: cost_of_full_table_scan, + kind: AccessMethodKind::Scan { + index: None, + iter_dir: IterationDirection::Forwards, + }, + }; + let rowid_column_idx = table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + for csmap in constraints + .iter() + .filter(|csmap| csmap.table_no == table_index) + { + let index_info = match &csmap.lookup { + ConstraintLookup::Index(index) => IndexInfo { + unique: index.unique, + covering: table_reference.index_is_covering(index), + column_count: index.columns.len(), + }, + ConstraintLookup::Rowid => IndexInfo { + unique: true, // rowids are always unique + covering: false, + column_count: 1, + }, + ConstraintLookup::EphemeralIndex => continue, + }; + let usable_constraints = + usable_constraints_for_join_order(&csmap.constraints, table_index, join_order); + let cost = estimate_cost_for_scan_or_seek( + Some(index_info), + &usable_constraints, + input_cardinality, + ); + + let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { + let mut all_same_direction = true; + let mut all_opposite_direction = true; + for i in 0..order_target.0.len().min(index_info.column_count) { + let correct_table = order_target.0[i].table_no == table_index; + let correct_column = { + match &csmap.lookup { + ConstraintLookup::Index(index) => { + index.columns[i].pos_in_table == order_target.0[i].column_no + } + ConstraintLookup::Rowid => { + rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) + } + ConstraintLookup::EphemeralIndex => unreachable!(), + } + }; + if !correct_table || !correct_column { + all_same_direction = false; + all_opposite_direction = false; + break; + } + let correct_order = { + match &csmap.lookup { + ConstraintLookup::Index(index) => { + order_target.0[i].order == index.columns[i].order + } + ConstraintLookup::Rowid => order_target.0[i].order == SortOrder::Asc, + ConstraintLookup::EphemeralIndex => unreachable!(), + } + }; + if correct_order { + all_opposite_direction = false; + } else { + all_same_direction = false; + } + } + if all_same_direction || all_opposite_direction { + Cost(1.0) + } else { + Cost(0.0) + } + } else { + Cost(0.0) + }; + if cost < best_access_method.cost + order_satisfiability_bonus { + best_access_method.cost = cost; + best_access_method.set_constraints(&csmap.lookup, &usable_constraints); + } + } + + let iter_dir = if let Some(order_target) = maybe_order_target { + // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards + let index = match &best_access_method.kind { + AccessMethodKind::Scan { index, .. } => index.as_ref(), + AccessMethodKind::Search { index, .. } => index.as_ref(), + }; + let mut should_use_backwards = true; + let num_cols = index.map_or(1, |i| i.columns.len()); + for i in 0..order_target.0.len().min(num_cols) { + let correct_table = order_target.0[i].table_no == table_index; + let correct_column = { + match index { + Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, + None => { + rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) + } + } + }; + if !correct_table || !correct_column { + should_use_backwards = false; + break; + } + let correct_order = { + match index { + Some(index) => order_target.0[i].order == index.columns[i].order, + None => order_target.0[i].order == SortOrder::Asc, + } + }; + if correct_order { + should_use_backwards = false; + break; + } + } + if should_use_backwards { + IterationDirection::Backwards + } else { + IterationDirection::Forwards + } + } else { + IterationDirection::Forwards + }; + best_access_method.set_iter_dir(iter_dir); + + Ok(best_access_method) +} diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs new file mode 100644 index 000000000..b9beb2425 --- /dev/null +++ b/core/translate/optimizer/constraints.rs @@ -0,0 +1,382 @@ +use std::{cmp::Ordering, collections::HashMap, sync::Arc}; + +use crate::{ + schema::Index, + translate::{ + expr::{as_binary_components, unwrap_parens}, + plan::{JoinOrderMember, TableReference, WhereTerm}, + planner::{table_mask_from_expr, TableMask}, + }, + Result, +}; +use limbo_sqlite3_parser::ast::{self, SortOrder}; +#[derive(Debug, Clone)] +pub struct Constraint { + /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), + /// since the RHS '10' is the constraining expression and it's part of the second term in the WHERE clause. + pub where_clause_pos: (usize, BinaryExprSide), + /// The operator of the constraint, e.g. =, >, < + pub operator: ast::Operator, + /// The position of the index column in the index, e.g. if the index is (a,b,c) and the constraint is on b, then index_column_pos is 1. + /// For Rowid constraints this is always 0. + pub index_col_pos: usize, + /// The position of the constrained column in the table. + pub table_col_pos: usize, + /// The sort order of the index column, ASC or DESC. For Rowid constraints this is always ASC. + pub sort_order: SortOrder, + /// Bitmask of tables that are required to be on the left side of the constrained table, + /// e.g. in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, the lhs_mask contains t2 and t3. + pub lhs_mask: TableMask, +} + +#[derive(Debug, Clone)] +/// Lookup denotes how a given set of [Constraint]s can be used to access a table. +/// +/// Lookup::Index(index) means that the constraints can be used to access the table using the given index. +/// Lookup::Rowid means that the constraints can be used to access the table using the table's rowid column. +/// Lookup::EphemeralIndex means that the constraints are not useful for accessing the table, +/// but an ephemeral index can be built ad-hoc to use them. +pub enum ConstraintLookup { + Index(Arc), + Rowid, + EphemeralIndex, +} + +#[derive(Debug)] +/// A collection of [Constraint]s for a given (table, index) pair. +pub struct Constraints { + pub lookup: ConstraintLookup, + pub table_no: usize, + pub constraints: Vec, +} + +/// Helper enum for [Constraint] to indicate which side of a binary comparison expression is being compared to the index column. +/// For example, if the where clause is "WHERE x = 10" and there's an index on x, +/// the [Constraint] for the where clause term "x = 10" will have a [BinaryExprSide::Rhs] +/// because the right hand side expression "10" is being compared to the index column "x". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryExprSide { + Lhs, + Rhs, +} + +/// Precompute all potentially usable [Constraints] from a WHERE clause. +/// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. +pub fn constraints_from_where_clause( + where_clause: &[WhereTerm], + table_references: &[TableReference], + available_indexes: &HashMap>>, +) -> Result> { + let mut constraints = Vec::new(); + for (table_no, table_reference) in table_references.iter().enumerate() { + let rowid_alias_column = table_reference + .columns() + .iter() + .position(|c| c.is_rowid_alias); + + let mut cs = Constraints { + lookup: ConstraintLookup::Rowid, + table_no, + constraints: Vec::new(), + }; + let mut cs_ephemeral = Constraints { + lookup: ConstraintLookup::EphemeralIndex, + table_no, + constraints: Vec::new(), + }; + for (i, term) in where_clause.iter().enumerate() { + let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { + continue; + }; + if let Some(outer_join_tbl) = term.from_outer_join { + if outer_join_tbl != table_no { + continue; + } + } + match lhs { + ast::Expr::Column { table, column, .. } => { + if *table == table_no { + if rowid_alias_column.map_or(false, |idx| *column == idx) { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator, + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } else { + cs_ephemeral.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator, + index_col_pos: 0, + table_col_pos: *column, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no && rowid_alias_column.is_some() { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator, + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + } + _ => {} + }; + match rhs { + ast::Expr::Column { table, column, .. } => { + if *table == table_no { + if rowid_alias_column.map_or(false, |idx| *column == idx) { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(operator), + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } else { + cs_ephemeral.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(operator), + index_col_pos: 0, + table_col_pos: *column, + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + } + ast::Expr::RowId { table, .. } => { + if *table == table_no && rowid_alias_column.is_some() { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(operator), + index_col_pos: 0, + table_col_pos: rowid_alias_column.unwrap(), + sort_order: SortOrder::Asc, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + _ => {} + }; + } + // First sort by position, with equalities first within each position + cs.constraints.sort_by(|a, b| { + let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); + if pos_cmp == Ordering::Equal { + // If same position, sort equalities first + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + } else { + pos_cmp + } + }); + cs_ephemeral.constraints.sort_by(|a, b| { + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + }); + + // Deduplicate by position, keeping first occurrence (which will be equality if one exists) + cs.constraints.dedup_by_key(|c| c.index_col_pos); + + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for constraint in cs.constraints.iter() { + if constraint.index_col_pos != last_pos { + if constraint.index_col_pos != last_pos + 1 { + break; + } + last_pos = constraint.index_col_pos; + } + i += 1; + } + cs.constraints.truncate(i); + + // Truncate after the first inequality + if let Some(first_inequality) = cs + .constraints + .iter() + .position(|c| c.operator != ast::Operator::Equals) + { + cs.constraints.truncate(first_inequality + 1); + } + if rowid_alias_column.is_some() { + constraints.push(cs); + } + constraints.push(cs_ephemeral); + + let indexes = available_indexes.get(table_reference.table.get_name()); + if let Some(indexes) = indexes { + for index in indexes { + let mut cs = Constraints { + lookup: ConstraintLookup::Index(index.clone()), + table_no, + constraints: Vec::new(), + }; + for (i, term) in where_clause.iter().enumerate() { + let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { + continue; + }; + if let Some(outer_join_tbl) = term.from_outer_join { + if outer_join_tbl != table_no { + continue; + } + } + if let Some(position_in_index) = + get_column_position_in_index(lhs, table_no, index)? + { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator, + index_col_pos: position_in_index, + table_col_pos: { + let ast::Expr::Column { column, .. } = unwrap_parens(lhs)? else { + crate::bail_parse_error!("expected column in index constraint"); + }; + *column + }, + sort_order: index.columns[position_in_index].order, + lhs_mask: table_mask_from_expr(rhs)?, + }); + } + if let Some(position_in_index) = + get_column_position_in_index(rhs, table_no, index)? + { + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(operator), + index_col_pos: position_in_index, + table_col_pos: { + let ast::Expr::Column { column, .. } = unwrap_parens(rhs)? else { + crate::bail_parse_error!("expected column in index constraint"); + }; + *column + }, + sort_order: index.columns[position_in_index].order, + lhs_mask: table_mask_from_expr(lhs)?, + }); + } + } + // First sort by position, with equalities first within each position + cs.constraints.sort_by(|a, b| { + let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); + if pos_cmp == Ordering::Equal { + // If same position, sort equalities first + if a.operator == ast::Operator::Equals { + Ordering::Less + } else if b.operator == ast::Operator::Equals { + Ordering::Greater + } else { + Ordering::Equal + } + } else { + pos_cmp + } + }); + + // Deduplicate by position, keeping first occurrence (which will be equality if one exists) + cs.constraints.dedup_by_key(|c| c.index_col_pos); + + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for constraint in cs.constraints.iter() { + if constraint.index_col_pos != last_pos { + if constraint.index_col_pos != last_pos + 1 { + break; + } + last_pos = constraint.index_col_pos; + } + i += 1; + } + cs.constraints.truncate(i); + + // Truncate after the first inequality + if let Some(first_inequality) = cs + .constraints + .iter() + .position(|c| c.operator != ast::Operator::Equals) + { + cs.constraints.truncate(first_inequality + 1); + } + constraints.push(cs); + } + } + } + + Ok(constraints) +} + +pub fn usable_constraints_for_join_order<'a>( + cs: &'a [Constraint], + table_index: usize, + join_order: &[JoinOrderMember], +) -> &'a [Constraint] { + let mut usable_until = 0; + for constraint in cs.iter() { + let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); + if other_side_refers_to_self { + break; + } + let lhs_mask = TableMask::from_iter( + join_order + .iter() + .take(join_order.len() - 1) + .map(|j| j.table_no), + ); + let all_required_tables_are_on_left_side = lhs_mask.contains_all(&constraint.lhs_mask); + if !all_required_tables_are_on_left_side { + break; + } + usable_until += 1; + } + &cs[..usable_until] +} + +/// Get the position of a column in an index +/// For example, if there is an index on table T(x,y) then y's position in the index is 1. +fn get_column_position_in_index( + expr: &ast::Expr, + table_index: usize, + index: &Arc, +) -> Result> { + let ast::Expr::Column { table, column, .. } = unwrap_parens(expr)? else { + return Ok(None); + }; + if *table != table_index { + return Ok(None); + } + Ok(index.column_table_pos_to_index_pos(*column)) +} + +fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { + match op { + ast::Operator::Equals => ast::Operator::Equals, + ast::Operator::Greater => ast::Operator::Less, + ast::Operator::GreaterEquals => ast::Operator::LessEquals, + ast::Operator::Less => ast::Operator::Greater, + ast::Operator::LessEquals => ast::Operator::GreaterEquals, + _ => panic!("unexpected operator: {:?}", op), + } +} diff --git a/core/translate/optimizer/cost.rs b/core/translate/optimizer/cost.rs new file mode 100644 index 000000000..5c8142be7 --- /dev/null +++ b/core/translate/optimizer/cost.rs @@ -0,0 +1,103 @@ +use limbo_sqlite3_parser::ast; + +use super::constraints::Constraint; + +/// A simple newtype wrapper over a f64 that represents the cost of an operation. +/// +/// This is used to estimate the cost of scans, seeks, and joins. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Cost(pub f64); + +impl std::ops::Add for Cost { + type Output = Cost; + + fn add(self, other: Cost) -> Cost { + Cost(self.0 + other.0) + } +} + +impl std::ops::Deref for Cost { + type Target = f64; + + fn deref(&self) -> &f64 { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndexInfo { + pub unique: bool, + pub column_count: usize, + pub covering: bool, +} + +pub const ESTIMATED_HARDCODED_ROWS_PER_TABLE: usize = 1000000; +pub const ESTIMATED_HARDCODED_ROWS_PER_PAGE: usize = 50; // roughly 80 bytes per 4096 byte page + +pub fn estimate_page_io_cost(rowcount: f64) -> Cost { + Cost((rowcount as f64 / ESTIMATED_HARDCODED_ROWS_PER_PAGE as f64).ceil()) +} + +/// Estimate the cost of a scan or seek operation. +/// +/// This is a very simple model that estimates the number of pages read +/// based on the number of rows read, ignoring any CPU costs. +pub fn estimate_cost_for_scan_or_seek( + index_info: Option, + constraints: &[Constraint], + input_cardinality: f64, +) -> Cost { + let Some(index_info) = index_info else { + return estimate_page_io_cost( + input_cardinality * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, + ); + }; + + let final_constraint_is_range = constraints + .last() + .map_or(false, |c| c.operator != ast::Operator::Equals); + let equalities_count = constraints + .iter() + .take(if final_constraint_is_range { + constraints.len() - 1 + } else { + constraints.len() + }) + .count() as f64; + + let cost_multiplier = match ( + index_info.unique, + index_info.column_count as f64, + equalities_count, + ) { + // no equalities: let's assume range query selectivity is 0.4. if final constraint is not range and there are no equalities, it means full table scan incoming + (_, _, 0.0) => { + if final_constraint_is_range { + 0.4 + } else { + 1.0 + } + } + // on an unique index if we have equalities across all index columns, assume very high selectivity + (true, index_cols, eq_count) if eq_count == index_cols => 0.01, + (false, index_cols, eq_count) if eq_count == index_cols => 0.1, + // some equalities: let's assume each equality has a selectivity of 0.1 and range query selectivity is 0.4 + (_, _, eq_count) => { + let mut multiplier = 1.0; + for _ in 0..(eq_count as usize) { + multiplier *= 0.1; + } + multiplier * if final_constraint_is_range { 4.0 } else { 1.0 } + } + }; + + // little bonus for covering indexes + let covering_multiplier = if index_info.covering { 0.9 } else { 1.0 }; + + estimate_page_io_cost( + cost_multiplier + * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * input_cardinality + * covering_multiplier, + ) +} diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs new file mode 100644 index 000000000..572ccc0dc --- /dev/null +++ b/core/translate/optimizer/join.rs @@ -0,0 +1,1412 @@ +use std::{cell::RefCell, collections::HashMap}; + +use limbo_sqlite3_parser::ast; + +use crate::{ + translate::{ + expr::as_binary_components, + optimizer::{cost::Cost, order::plan_satisfies_order_target}, + plan::{EvalAt, JoinOrderMember, TableReference, WhereTerm}, + planner::{determine_where_to_eval_expr, TableMask}, + }, + Result, +}; + +use super::{ + access_method::{find_best_access_method_for_join_order, AccessMethod}, + constraints::Constraints, + cost::ESTIMATED_HARDCODED_ROWS_PER_TABLE, + order::OrderTarget, +}; + +/// Represents an n-ary join, anywhere from 1 table to N tables. +#[derive(Debug, Clone)] +pub struct JoinN { + /// Identifiers of the tables in the best_plan + pub table_numbers: Vec, + /// The best access methods for the best_plans + pub best_access_methods: Vec, + /// The estimated number of rows returned by joining these n tables together. + pub output_cardinality: usize, + /// Estimated execution cost of this N-ary join. + pub cost: Cost, +} + +/// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. +const SELECTIVITY_EQ: f64 = 0.01; +/// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. +const SELECTIVITY_RANGE: f64 = 0.4; +/// In lieu of statistics, we estimate that other filters will reduce the output set to 90% of its size. +const SELECTIVITY_OTHER: f64 = 0.9; + +/// Join n-1 tables with the n'th table. +pub fn join_lhs_and_rhs<'a>( + lhs: Option<&JoinN>, + rhs_table_number: usize, + rhs_table_reference: &TableReference, + where_clause: &Vec, + constraints: &'a [Constraints], + join_order: &[JoinOrderMember], + maybe_order_target: Option<&OrderTarget>, + access_methods_arena: &'a RefCell>>, +) -> Result { + // The input cardinality for this join is the output cardinality of the previous join. + // For example, in a 2-way join, if the left table has 1000 rows, and the right table will return 2 rows for each of the left table's rows, + // then the output cardinality of the join will be 2000. + let input_cardinality = lhs.map_or(1, |l| l.output_cardinality); + + let best_access_method = find_best_access_method_for_join_order( + rhs_table_number, + rhs_table_reference, + constraints, + &join_order, + maybe_order_target, + input_cardinality as f64, + )?; + + let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); + let cost = lhs_cost + best_access_method.cost; + + let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { + let mut numbers = l.table_numbers.clone(); + numbers.push(rhs_table_number); + numbers + }); + + access_methods_arena.borrow_mut().push(best_access_method); + let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); + best_access_methods.push(access_methods_arena.borrow().len() - 1); + + // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. + let output_cardinality_multiplier = where_clause + .iter() + .filter_map(|term| { + // Skip terms that are not binary comparisons + let Ok(Some((lhs, op, rhs))) = as_binary_components(&term.expr) else { + return None; + }; + // Skip terms that cannot be evaluated at this table's loop level + if !term.should_eval_at_loop(join_order.len() - 1, join_order) { + return None; + } + + // If both lhs and rhs refer to columns from this table, we can't use this constraint + // because we can't use the index to satisfy the condition. + // Examples: + // - WHERE t.x > t.y + // - WHERE t.x + 1 > t.y - 5 + // - WHERE t.x = (t.x) + let Ok(eval_at_left) = determine_where_to_eval_expr(&lhs, join_order) else { + return None; + }; + let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { + return None; + }; + if eval_at_left == EvalAt::Loop(join_order.len() - 1) + && eval_at_right == EvalAt::Loop(join_order.len() - 1) + { + return None; + } + + Some((lhs, op, rhs)) + }) + .filter_map(|(lhs, op, rhs)| { + // Skip terms where neither lhs nor rhs refer to columns from this table + if let ast::Expr::Column { table, column, .. } = lhs { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some((&columns[*column], op)) + } + } else { + None + } + .or_else(|| { + if let ast::Expr::Column { table, column, .. } = rhs { + if *table != rhs_table_number { + None + } else { + let columns = rhs_table_reference.columns(); + Some((&columns[*column], op)) + } + } else { + None + } + }) + }) + .map(|(column, op)| match op { + ast::Operator::Equals => { + if column.is_rowid_alias || column.primary_key { + 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + } else { + SELECTIVITY_EQ + } + } + ast::Operator::Greater => SELECTIVITY_RANGE, + ast::Operator::GreaterEquals => SELECTIVITY_RANGE, + ast::Operator::Less => SELECTIVITY_RANGE, + ast::Operator::LessEquals => SELECTIVITY_RANGE, + _ => SELECTIVITY_OTHER, + }) + .product::(); + + // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. + // If this table is the rightmost table in the join order, we multiply by the input cardinality, + // which is the output cardinality of the previous tables. + let output_cardinality = (input_cardinality as f64 + * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 + * output_cardinality_multiplier) + .ceil() as usize; + + Ok(JoinN { + table_numbers: new_numbers, + best_access_methods, + output_cardinality, + cost, + }) +} + +/// The result of [compute_best_join_order]. +#[derive(Debug)] +pub struct BestJoinOrderResult { + /// The best plan overall. + pub best_plan: JoinN, + /// The best plan for the given order target, if it isn't the overall best. + pub best_ordered_plan: Option, +} + +/// Compute the best way to join a given set of tables. +/// Returns the best [JoinN] if one exists, otherwise returns None. +pub fn compute_best_join_order<'a>( + table_references: &[TableReference], + where_clause: &Vec, + maybe_order_target: Option<&OrderTarget>, + constraints: &'a [Constraints], + access_methods_arena: &'a RefCell>>, +) -> Result> { + // Skip work if we have no tables to consider. + if table_references.is_empty() { + return Ok(None); + } + + let num_tables = table_references.len(); + + // Compute naive left-to-right plan to use as pruning threshold + let naive_plan = compute_naive_left_deep_plan( + table_references, + where_clause, + maybe_order_target, + access_methods_arena, + &constraints, + )?; + + // Keep track of both 1. the best plan overall (not considering sorting), and 2. the best ordered plan (which might not be the same). + // We assign Some Cost (tm) to any required sort operation, so the best ordered plan may end up being + // the one we choose, if the cost reduction from avoiding sorting brings it below the cost of the overall best one. + let mut best_ordered_plan: Option = None; + let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { + plan_satisfies_order_target( + &naive_plan, + &access_methods_arena, + table_references, + order_target, + ) + } else { + false + }; + + // If we have one table, then the "naive left-to-right plan" is always the best. + if table_references.len() == 1 { + return Ok(Some(BestJoinOrderResult { + best_plan: naive_plan, + best_ordered_plan: None, + })); + } + let mut best_plan = naive_plan; + + // Reuse a single mutable join order to avoid allocating join orders per permutation. + let mut join_order = Vec::with_capacity(num_tables); + join_order.push(JoinOrderMember { + table_no: 0, + is_outer: false, + }); + + // Keep track of the current best cost so we can short-circuit planning for subplans + // that already exceed the cost of the current best plan. + let cost_upper_bound = best_plan.cost; + let cost_upper_bound_ordered = { + if best_plan_is_also_ordered { + cost_upper_bound + } else { + Cost(f64::MAX) + } + }; + + // Keep track of the best plan for a given subset of tables. + // Consider this example: we have tables a,b,c,d to join. + // if we find that 'b JOIN a' is better than 'a JOIN b', then we don't need to even try + // to do 'a JOIN b JOIN c', because we know 'b JOIN a JOIN c' is going to be better. + // This is due to the commutativity and associativity of inner joins. + let mut best_plan_memo: HashMap = HashMap::new(); + + // Dynamic programming base case: calculate the best way to access each single table, as if + // there were no other tables. + for i in 0..num_tables { + let mut mask = TableMask::new(); + mask.add_table(i); + let table_ref = &table_references[i]; + join_order[0] = JoinOrderMember { + table_no: i, + is_outer: false, + }; + assert!(join_order.len() == 1); + let rel = join_lhs_and_rhs( + None, + i, + table_ref, + where_clause, + &constraints, + &join_order, + maybe_order_target, + access_methods_arena, + )?; + best_plan_memo.insert(mask, rel); + } + join_order.clear(); + + // As mentioned, inner joins are commutative. Outer joins are NOT. + // Example: + // "a LEFT JOIN b" can NOT be reordered as "b LEFT JOIN a". + // If there are outer joins in the plan, ensure correct ordering. + let left_join_illegal_map = { + let left_join_count = table_references + .iter() + .filter(|t| t.join_info.as_ref().map_or(false, |j| j.outer)) + .count(); + if left_join_count == 0 { + None + } else { + // map from rhs table index to lhs table index + let mut left_join_illegal_map: HashMap = + HashMap::with_capacity(left_join_count); + for (i, _) in table_references.iter().enumerate() { + for j in i + 1..table_references.len() { + if table_references[j] + .join_info + .as_ref() + .map_or(false, |j| j.outer) + { + // bitwise OR the masks + if let Some(illegal_lhs) = left_join_illegal_map.get_mut(&i) { + illegal_lhs.add_table(j); + } else { + let mut mask = TableMask::new(); + mask.add_table(j); + left_join_illegal_map.insert(i, mask); + } + } + } + } + Some(left_join_illegal_map) + } + }; + + // Now that we have our single-table base cases, we can start considering join subsets of 2 tables and more. + // Try to join each single table to each other table. + for subset_size in 2..=num_tables { + for mask in generate_join_bitmasks(num_tables, subset_size) { + // Keep track of the best way to join this subset of tables. + // Take the (a,b,c,d) example from above: + // E.g. for "a JOIN b JOIN c", the possibilities are (a,b,c), (a,c,b), (b,a,c) and so on. + // If we find out (b,a,c) is the best way to join these three, then we ONLY need to compute + // the cost of (b,a,c,d) in the final step, because (a,b,c,d) (and all others) are guaranteed to be worse. + let mut best_for_mask: Option = None; + // also keep track of the best plan for this subset that orders the rows in an Interesting Way (tm), + // i.e. allows us to eliminate sort operations downstream. + let (mut best_ordered_for_mask, mut best_for_mask_is_also_ordered) = (None, false); + + // Try to join all subsets (masks) with all other tables. + // In this block, LHS is always (n-1) tables, and RHS is a single table. + for rhs_idx in 0..num_tables { + // If the RHS table isn't a member of this join subset, skip. + if !mask.contains_table(rhs_idx) { + continue; + } + + // If there are no other tables except RHS, skip. + let lhs_mask = mask.without_table(rhs_idx); + if lhs_mask.is_empty() { + continue; + } + + // If this join ordering would violate LEFT JOIN ordering restrictions, skip. + if let Some(illegal_lhs) = left_join_illegal_map + .as_ref() + .and_then(|deps| deps.get(&rhs_idx)) + { + let legal = !lhs_mask.intersects(illegal_lhs); + if !legal { + continue; // Don't allow RHS before its LEFT in LEFT JOIN + } + } + + // If the already cached plan for this subset was too crappy to consider, + // then joining it with RHS won't help. Skip. + let Some(lhs) = best_plan_memo.get(&lhs_mask) else { + continue; + }; + + // Build a JoinOrder out of the table bitmask we are now considering. + for table_no in lhs.table_numbers.iter() { + join_order.push(JoinOrderMember { + table_no: *table_no, + is_outer: table_references[*table_no] + .join_info + .as_ref() + .map_or(false, |j| j.outer), + }); + } + join_order.push(JoinOrderMember { + table_no: rhs_idx, + is_outer: table_references[rhs_idx] + .join_info + .as_ref() + .map_or(false, |j| j.outer), + }); + assert!(join_order.len() == subset_size); + + // Calculate the best way to join LHS with RHS. + let rel = join_lhs_and_rhs( + Some(lhs), + rhs_idx, + &table_references[rhs_idx], + where_clause, + &constraints, + &join_order, + maybe_order_target, + access_methods_arena, + )?; + join_order.clear(); + + // Since cost_upper_bound_ordered is always >= to cost_upper_bound, + // if the cost we calculated for this plan is worse than cost_upper_bound_ordered, + // this join subset is already worse than our best plan for the ENTIRE query, so skip. + if rel.cost >= cost_upper_bound_ordered { + continue; + } + + let satisfies_order_target = if let Some(ref order_target) = maybe_order_target { + plan_satisfies_order_target( + &rel, + &access_methods_arena, + table_references, + order_target, + ) + } else { + false + }; + + // If this plan is worse than our overall best, it might still be the best ordered plan. + if rel.cost >= cost_upper_bound { + // But if it isn't, skip. + if !satisfies_order_target { + continue; + } + let existing_ordered_cost: Cost = best_ordered_for_mask + .as_ref() + .map_or(Cost(f64::MAX), |p: &JoinN| p.cost); + if rel.cost < existing_ordered_cost { + best_ordered_for_mask = Some(rel); + } + } else if best_for_mask.is_none() || rel.cost < best_for_mask.as_ref().unwrap().cost + { + best_for_mask = Some(rel); + best_for_mask_is_also_ordered = satisfies_order_target; + } + } + + if let Some(rel) = best_ordered_for_mask.take() { + let cost = rel.cost; + let has_all_tables = mask.table_count() == num_tables; + if has_all_tables && cost_upper_bound_ordered > cost { + best_ordered_plan = Some(rel); + } + } + + if let Some(rel) = best_for_mask.take() { + let cost = rel.cost; + let has_all_tables = mask.table_count() == num_tables; + if has_all_tables { + if cost_upper_bound > cost { + best_plan = rel; + best_plan_is_also_ordered = best_for_mask_is_also_ordered; + } + } else { + best_plan_memo.insert(mask, rel); + } + } + } + } + + Ok(Some(BestJoinOrderResult { + best_plan, + best_ordered_plan: if best_plan_is_also_ordered { + None + } else { + best_ordered_plan + }, + })) +} + +/// Specialized version of [compute_best_join_order] that just joins tables in the order they are given +/// in the SQL query. This is used as an upper bound for any other plans -- we can give up enumerating +/// permutations if they exceed this cost during enumeration. +pub fn compute_naive_left_deep_plan<'a>( + table_references: &[TableReference], + where_clause: &Vec, + maybe_order_target: Option<&OrderTarget>, + access_methods_arena: &'a RefCell>>, + constraints: &'a [Constraints], +) -> Result { + let n = table_references.len(); + assert!(n > 0); + + let join_order = table_references + .iter() + .enumerate() + .map(|(i, t)| JoinOrderMember { + table_no: i, + is_outer: t.join_info.as_ref().map_or(false, |j| j.outer), + }) + .collect::>(); + + // Start with first table + let mut best_plan = join_lhs_and_rhs( + None, + 0, + &table_references[0], + where_clause, + constraints, + &join_order[..1], + maybe_order_target, + access_methods_arena, + )?; + + // Add remaining tables one at a time from left to right + for i in 1..n { + best_plan = join_lhs_and_rhs( + Some(&best_plan), + i, + &table_references[i], + where_clause, + constraints, + &join_order[..i + 1], + maybe_order_target, + access_methods_arena, + )?; + } + + Ok(best_plan) +} + +/// Iterator that generates all possible size k bitmasks for a given number of tables. +/// For example, given: 3 tables and k=2, the bitmasks are: +/// - 0b011 (tables 0, 1) +/// - 0b101 (tables 0, 2) +/// - 0b110 (tables 1, 2) +/// +/// This is used in the dynamic programming approach to finding the best way to join a subset of N tables. +struct JoinBitmaskIter { + current: u128, + max_exclusive: u128, +} + +impl JoinBitmaskIter { + fn new(table_number_max_exclusive: usize, how_many: usize) -> Self { + Self { + current: (1 << how_many) - 1, // Start with smallest k-bit number (e.g., 000111 for k=3) + max_exclusive: 1 << table_number_max_exclusive, + } + } +} + +impl Iterator for JoinBitmaskIter { + type Item = TableMask; + + fn next(&mut self) -> Option { + if self.current >= self.max_exclusive { + return None; + } + + let result = TableMask::from_bits(self.current); + + // Gosper's hack: compute next k-bit combination in lexicographic order + let c = self.current & (!self.current + 1); // rightmost set bit + let r = self.current + c; // add it to get a carry + let ones = self.current ^ r; // changed bits + let ones = (ones >> 2) / c; // right-adjust shifted bits + self.current = r | ones; // form the next combination + + Some(result) + } +} + +/// Generate all possible bitmasks of size `how_many` for a given number of tables. +fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> JoinBitmaskIter { + JoinBitmaskIter::new(table_number_max_exclusive, how_many) +} + +#[cfg(test)] +mod tests { + use std::{rc::Rc, sync::Arc}; + + use limbo_sqlite3_parser::ast::{Expr, Operator, SortOrder}; + + use super::*; + use crate::{ + schema::{BTreeTable, Column, Index, IndexColumn, Table, Type}, + translate::{ + optimizer::{ + access_method::AccessMethodKind, + constraints::{constraints_from_where_clause, BinaryExprSide}, + }, + plan::{ColumnUsedMask, IterationDirection, JoinInfo, Operation}, + planner::TableMask, + }, + }; + + #[test] + fn test_generate_bitmasks() { + let bitmasks = generate_join_bitmasks(4, 2).collect::>(); + assert!(bitmasks.contains(&TableMask(0b110))); // {0,1} -- first bit is always set to 0 so that a Mask with value 0 means "no tables are referenced". + assert!(bitmasks.contains(&TableMask(0b1010))); // {0,2} + assert!(bitmasks.contains(&TableMask(0b1100))); // {1,2} + assert!(bitmasks.contains(&TableMask(0b10010))); // {0,3} + assert!(bitmasks.contains(&TableMask(0b10100))); // {1,3} + assert!(bitmasks.contains(&TableMask(0b11000))); // {2,3} + } + + #[test] + /// Test that [compute_best_join_order] returns None when there are no table references. + fn test_compute_best_join_order_empty() { + let table_references = vec![]; + let available_indexes = HashMap::new(); + let where_clause = vec![]; + + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + let result = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + /// Test that [compute_best_join_order] returns a table scan access method when the where clause is empty. + fn test_compute_best_join_order_single_table_no_indexes() { + let t1 = _create_btree_table("test_table", _create_column_list(&["id"], Type::Integer)); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + let available_indexes = HashMap::new(); + let where_clause = vec![]; + + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + // SELECT * from test_table + // expecting best_best_plan() not to do any work due to empty where clause. + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap() + .unwrap(); + // Should just be a table scan access method + assert!(matches!( + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if iter_dir == IterationDirection::Forwards + )); + } + + #[test] + /// Test that [compute_best_join_order] returns a RowidEq access method when the where clause has an EQ constraint on the rowid alias. + fn test_compute_best_join_order_single_table_rowid_eq() { + let t1 = _create_btree_table("test_table", vec![_create_column_rowid_alias("id")]); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, true), // table 0, column 0 (rowid) + ast::Operator::Equals, + _create_numeric_literal("42"), + )]; + + let access_methods_arena = RefCell::new(Vec::new()); + let available_indexes = HashMap::new(); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + // SELECT * FROM test_table WHERE id = 42 + // expecting a RowidEq access method because id is a rowid alias. + let result = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![0]); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + index: None, + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs), + ), + "expected rowid eq access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] returns an IndexScan access method when the where clause has an EQ constraint on a primary key. + fn test_compute_best_join_order_single_table_pk_eq() { + let t1 = _create_btree_table( + "test_table", + vec![_create_column_of_type("id", Type::Integer)], + ); + let table_references = vec![_create_table_reference(t1.clone(), None)]; + + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, false), // table 0, column 0 (id) + ast::Operator::Equals, + _create_numeric_literal("42"), + )]; + + let access_methods_arena = RefCell::new(Vec::new()); + let mut available_indexes = HashMap::new(); + let index = Arc::new(Index { + name: "sqlite_autoindex_test_table_1".to_string(), + table_name: "test_table".to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert("test_table".to_string(), vec![index]); + + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + // SELECT * FROM test_table WHERE id = 42 + // expecting an IndexScan access method because id is a primary key with an index + let result = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![0]); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" + ), + "expected index search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] moves the outer table to the inner position when an index can be used on it, but not the original inner table. + fn test_compute_best_join_order_two_tables() { + let t1 = _create_btree_table("table1", _create_column_list(&["id"], Type::Integer)); + let t2 = _create_btree_table("table2", _create_column_list(&["id"], Type::Integer)); + + let mut table_references = vec![ + _create_table_reference(t1.clone(), None), + _create_table_reference( + t2.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + let mut available_indexes = HashMap::new(); + // Index on the outer table (table1) + let index1 = Arc::new(Index { + name: "index1".to_string(), + table_name: "table1".to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert("table1".to_string(), vec![index1]); + + // SELECT * FROM table1 JOIN table2 WHERE table1.id = table2.id + // expecting table2 to be chosen first due to the index on table1.id + let where_clause = vec![_create_binary_expr( + _create_column_expr(0, 0, false), // table1.id + ast::Operator::Equals, + _create_column_expr(1, 0, false), // table2.id + )]; + + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + let result = compute_best_join_order( + &mut table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + assert_eq!(best_plan.table_numbers, vec![1, 0]); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "expected TableScan access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + ); + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + ); + } + + #[test] + /// Test that [compute_best_join_order] returns a sensible order and plan for three tables, each with indexes. + fn test_compute_best_join_order_three_tables_indexed() { + let table_orders = _create_btree_table( + "orders", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("customer_id", Type::Integer), + _create_column_of_type("total", Type::Integer), + ], + ); + let table_customers = _create_btree_table( + "customers", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("name", Type::Integer), + ], + ); + let table_order_items = _create_btree_table( + "order_items", + vec![ + _create_column_of_type("id", Type::Integer), + _create_column_of_type("order_id", Type::Integer), + _create_column_of_type("product_id", Type::Integer), + _create_column_of_type("quantity", Type::Integer), + ], + ); + + let table_references = vec![ + _create_table_reference(table_orders.clone(), None), + _create_table_reference( + table_customers.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + _create_table_reference( + table_order_items.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + const TABLE_NO_ORDERS: usize = 0; + const TABLE_NO_CUSTOMERS: usize = 1; + const TABLE_NO_ORDER_ITEMS: usize = 2; + + let mut available_indexes = HashMap::new(); + ["orders", "customers", "order_items"] + .iter() + .for_each(|table_name| { + // add primary key index called sqlite_autoindex__1 + let index_name = format!("sqlite_autoindex_{}_1", table_name); + let index = Arc::new(Index { + name: index_name, + table_name: table_name.to_string(), + columns: vec![IndexColumn { + name: "id".to_string(), + order: SortOrder::Asc, + pos_in_table: 0, + }], + unique: true, + ephemeral: false, + root_page: 1, + }); + available_indexes.insert(table_name.to_string(), vec![index]); + }); + let customer_id_idx = Arc::new(Index { + name: "orders_customer_id_idx".to_string(), + table_name: "orders".to_string(), + columns: vec![IndexColumn { + name: "customer_id".to_string(), + order: SortOrder::Asc, + pos_in_table: 1, + }], + unique: false, + ephemeral: false, + root_page: 1, + }); + let order_id_idx = Arc::new(Index { + name: "order_items_order_id_idx".to_string(), + table_name: "order_items".to_string(), + columns: vec![IndexColumn { + name: "order_id".to_string(), + order: SortOrder::Asc, + pos_in_table: 1, + }], + unique: false, + ephemeral: false, + root_page: 1, + }); + + available_indexes + .entry("orders".to_string()) + .and_modify(|v| v.push(customer_id_idx)); + available_indexes + .entry("order_items".to_string()) + .and_modify(|v| v.push(order_id_idx)); + + // SELECT * FROM orders JOIN customers JOIN order_items + // WHERE orders.customer_id = customers.id AND orders.id = order_items.order_id AND customers.id = 42 + // expecting customers to be chosen first due to the index on customers.id and it having a selective filter (=42) + // then orders to be chosen next due to the index on orders.customer_id + // then order_items to be chosen last due to the index on order_items.order_id + let where_clause = vec![ + // orders.customer_id = customers.id + _create_binary_expr( + _create_column_expr(TABLE_NO_ORDERS, 1, false), // orders.customer_id + ast::Operator::Equals, + _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id + ), + // orders.id = order_items.order_id + _create_binary_expr( + _create_column_expr(TABLE_NO_ORDERS, 0, false), // orders.id + ast::Operator::Equals, + _create_column_expr(TABLE_NO_ORDER_ITEMS, 1, false), // order_items.order_id + ), + // customers.id = 42 + _create_binary_expr( + _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id + ast::Operator::Equals, + _create_numeric_literal("42"), + ), + ]; + + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + let result = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + + // Customers (due to =42 filter) -> Orders (due to index on customer_id) -> Order_items (due to index on order_id) + assert_eq!( + best_plan.table_numbers, + vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] + ); + + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_customers_1", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + ); + + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_CUSTOMERS) && index.name == "orders_customer_id_idx", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + ); + + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, + AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_ORDERS) && index.name == "order_items_order_id_idx", + ), + "expected Search access method, got {:?}", + access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind + ); + } + + struct TestColumn { + name: String, + ty: Type, + is_rowid_alias: bool, + } + + impl Default for TestColumn { + fn default() -> Self { + Self { + name: "a".to_string(), + ty: Type::Integer, + is_rowid_alias: false, + } + } + } + + #[test] + fn test_join_order_three_tables_no_indexes() { + let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); + let t2 = _create_btree_table("t2", _create_column_list(&["id", "foo"], Type::Integer)); + let t3 = _create_btree_table("t3", _create_column_list(&["id", "foo"], Type::Integer)); + + let mut table_references = vec![ + _create_table_reference(t1.clone(), None), + _create_table_reference( + t2.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + _create_table_reference( + t3.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ), + ]; + + let where_clause = vec![ + // t2.foo = 42 (equality filter, more selective) + _create_binary_expr( + _create_column_expr(1, 1, false), // table 1, column 1 (foo) + ast::Operator::Equals, + _create_numeric_literal("42"), + ), + // t1.foo > 10 (inequality filter, less selective) + _create_binary_expr( + _create_column_expr(0, 1, false), // table 0, column 1 (foo) + ast::Operator::Greater, + _create_numeric_literal("10"), + ), + ]; + + let available_indexes = HashMap::new(); + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &mut table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap() + .unwrap(); + + // Verify that t2 is chosen first due to its equality filter + assert_eq!(best_plan.table_numbers[0], 1); + // Verify table scan is used since there are no indexes + assert!(matches!( + access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if iter_dir == IterationDirection::Forwards + )); + // Verify that t1 is chosen next due to its inequality filter + assert!(matches!( + access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if iter_dir == IterationDirection::Forwards + )); + // Verify that t3 is chosen last due to no filters + assert!(matches!( + access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if iter_dir == IterationDirection::Forwards + )); + } + + #[test] + /// Test that [compute_best_join_order] chooses a "fact table" as the outer table, + /// when it has a foreign key to all dimension tables. + fn test_compute_best_join_order_star_schema() { + const NUM_DIM_TABLES: usize = 9; + const FACT_TABLE_IDX: usize = 9; + + // Create fact table with foreign keys to all dimension tables + let mut fact_columns = vec![_create_column_rowid_alias("id")]; + for i in 0..NUM_DIM_TABLES { + fact_columns.push(_create_column_of_type( + &format!("dim{}_id", i), + Type::Integer, + )); + } + let fact_table = _create_btree_table("fact", fact_columns); + + // Create dimension tables, each with an id and value column + let dim_tables: Vec<_> = (0..NUM_DIM_TABLES) + .map(|i| { + _create_btree_table( + &format!("dim{}", i), + vec![ + _create_column_rowid_alias("id"), + _create_column_of_type("value", Type::Integer), + ], + ) + }) + .collect(); + + let mut where_clause = vec![]; + + // Add join conditions between fact and each dimension table + for i in 0..NUM_DIM_TABLES { + where_clause.push(_create_binary_expr( + _create_column_expr(FACT_TABLE_IDX, i + 1, false), // fact.dimX_id + ast::Operator::Equals, + _create_column_expr(i, 0, true), // dimX.id + )); + } + + let table_references = { + let mut refs = vec![_create_table_reference(dim_tables[0].clone(), None)]; + refs.extend(dim_tables.iter().skip(1).map(|t| { + _create_table_reference( + t.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + ) + })); + refs.push(_create_table_reference( + fact_table.clone(), + Some(JoinInfo { + outer: false, + using: None, + }), + )); + refs + }; + + let access_methods_arena = RefCell::new(Vec::new()); + let available_indexes = HashMap::new(); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + let result = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap(); + assert!(result.is_some()); + let BestJoinOrderResult { best_plan, .. } = result.unwrap(); + + // Expected optimal order: fact table as outer, with rowid seeks in any order on each dimension table + // Verify fact table is selected as the outer table as all the other tables can use SeekRowid + assert_eq!( + best_plan.table_numbers[0], FACT_TABLE_IDX, + "First table should be fact (table {}) due to available index, got table {} instead", + FACT_TABLE_IDX, best_plan.table_numbers[0] + ); + + // Verify access methods + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "First table (fact) should use table scan due to column filter" + ); + + for i in 1..best_plan.table_numbers.len() { + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind, + AccessMethodKind::Search { + index: None, + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(FACT_TABLE_IDX) + ), + "Table {} should use Search access method, got {:?}", + i + 1, + &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind + ); + } + } + + #[test] + /// Test that [compute_best_join_order] figures out that the tables form a "linked list" pattern + /// where a column in each table points to an indexed column in the next table, + /// and chooses the best order based on that. + fn test_compute_best_join_order_linked_list() { + const NUM_TABLES: usize = 5; + + // Create tables t1 -> t2 -> t3 -> t4 -> t5 where there is a foreign key from each table to the next + let mut tables = Vec::with_capacity(NUM_TABLES); + for i in 0..NUM_TABLES { + let mut columns = vec![_create_column_rowid_alias("id")]; + if i < NUM_TABLES - 1 { + columns.push(_create_column_of_type(&format!("next_id"), Type::Integer)); + } + tables.push(_create_btree_table(&format!("t{}", i + 1), columns)); + } + + let available_indexes = HashMap::new(); + + // Create table references + let table_references: Vec<_> = tables + .iter() + .map(|t| _create_table_reference(t.clone(), None)) + .collect(); + + // Create where clause linking each table to the next + let mut where_clause = Vec::new(); + for i in 0..NUM_TABLES - 1 { + where_clause.push(_create_binary_expr( + _create_column_expr(i, 1, false), // ti.next_id + ast::Operator::Equals, + _create_column_expr(i + 1, 0, true), // t(i+1).id + )); + } + + let access_methods_arena = RefCell::new(Vec::new()); + let constraints = + constraints_from_where_clause(&where_clause, &table_references, &available_indexes) + .unwrap(); + + // Run the optimizer + let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( + &table_references, + &where_clause, + None, + &constraints, + &access_methods_arena, + ) + .unwrap() + .unwrap(); + + // Verify the join order is exactly t1 -> t2 -> t3 -> t4 -> t5 + for i in 0..NUM_TABLES { + assert_eq!( + best_plan.table_numbers[i], i, + "Expected table {} at position {}, got table {} instead", + i, i, best_plan.table_numbers[i] + ); + } + + // Verify access methods: + // - First table should use Table scan + assert!( + matches!( + &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, + AccessMethodKind::Scan { index: None, iter_dir } + if *iter_dir == IterationDirection::Forwards + ), + "First table should use Table scan" + ); + + // all of the rest should use rowid equality + for i in 1..NUM_TABLES { + let method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind; + assert!( + matches!( + method, + AccessMethodKind::Search { + index: None, + iter_dir, + constraints, + } + if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(i-1) + ), + "Table {} should use Search access method, got {:?}", + i + 1, + method + ); + } + } + + fn _create_column(c: &TestColumn) -> Column { + Column { + name: Some(c.name.clone()), + ty: c.ty, + ty_str: c.ty.to_string(), + is_rowid_alias: c.is_rowid_alias, + primary_key: false, + notnull: false, + default: None, + } + } + fn _create_column_of_type(name: &str, ty: Type) -> Column { + _create_column(&TestColumn { + name: name.to_string(), + ty, + is_rowid_alias: false, + }) + } + + fn _create_column_list(names: &[&str], ty: Type) -> Vec { + names + .iter() + .map(|name| _create_column_of_type(name, ty)) + .collect() + } + + fn _create_column_rowid_alias(name: &str) -> Column { + _create_column(&TestColumn { + name: name.to_string(), + ty: Type::Integer, + is_rowid_alias: true, + }) + } + + /// Creates a BTreeTable with the given name and columns + fn _create_btree_table(name: &str, columns: Vec) -> Rc { + Rc::new(BTreeTable { + root_page: 1, // Page number doesn't matter for tests + name: name.to_string(), + primary_key_columns: vec![], + columns, + has_rowid: true, + is_strict: false, + }) + } + + /// Creates a TableReference for a BTreeTable + fn _create_table_reference( + table: Rc, + join_info: Option, + ) -> TableReference { + let name = table.name.clone(); + TableReference { + table: Table::BTree(table), + op: Operation::Scan { + iter_dir: IterationDirection::Forwards, + index: None, + }, + identifier: name, + join_info, + col_used_mask: ColumnUsedMask::new(), + } + } + + /// Creates a column expression + fn _create_column_expr(table: usize, column: usize, is_rowid_alias: bool) -> Expr { + Expr::Column { + database: None, + table, + column, + is_rowid_alias, + } + } + + /// Creates a binary expression for a WHERE clause + fn _create_binary_expr(lhs: Expr, op: Operator, rhs: Expr) -> WhereTerm { + WhereTerm { + expr: Expr::Binary(Box::new(lhs), op, Box::new(rhs)), + from_outer_join: None, + } + } + + /// Creates a numeric literal expression + fn _create_numeric_literal(value: &str) -> Expr { + Expr::Literal(ast::Literal::Numeric(value.to_string())) + } +} diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index c08e4eb3d..9aa29239b 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -1,25 +1,38 @@ use std::{cell::RefCell, cmp::Ordering, collections::HashMap, sync::Arc}; +use access_method::AccessMethodKind; +use constraints::{ + constraints_from_where_clause, usable_constraints_for_join_order, BinaryExprSide, Constraint, + ConstraintLookup, +}; +use cost::Cost; +use join::{compute_best_join_order, BestJoinOrderResult}; use limbo_sqlite3_parser::ast::{self, Expr, SortOrder}; +use order::{compute_order_target, plan_satisfies_order_target, EliminatesSort}; use crate::{ parameters::PARAM_PREFIX, schema::{Index, IndexColumn, Schema}, translate::plan::TerminationKey, types::SeekOp, - util::exprs_are_equivalent, Result, }; use super::{ emitter::Resolver, + expr::unwrap_parens, plan::{ - DeletePlan, EvalAt, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, - SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, + DeletePlan, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, SeekDef, + SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, }, - planner::{determine_where_to_eval_expr, table_mask_from_expr, TableMask}, }; +pub(crate) mod access_method; +pub(crate) mod constraints; +pub(crate) mod cost; +pub(crate) mod join; +pub(crate) mod order; + pub fn optimize_plan(plan: &mut Plan, schema: &Schema) -> Result<()> { match plan { Plan::Select(plan) => optimize_select_plan(plan, schema), @@ -106,856 +119,6 @@ fn optimize_subqueries(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { Ok(()) } -/// Represents an n-ary join, anywhere from 1 table to N tables. -#[derive(Debug, Clone)] -struct JoinN { - /// Identifiers of the tables in the best_plan - pub table_numbers: Vec, - /// The best access methods for the best_plans - pub best_access_methods: Vec, - /// The estimated number of rows returned by joining these n tables together. - pub output_cardinality: usize, - /// Estimated execution cost of this N-ary join. - pub cost: Cost, -} - -/// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. -const SELECTIVITY_EQ: f64 = 0.01; -/// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. -const SELECTIVITY_RANGE: f64 = 0.4; -/// In lieu of statistics, we estimate that other filters will reduce the output set to 90% of its size. -const SELECTIVITY_OTHER: f64 = 0.9; - -/// Join n-1 tables with the n'th table. -fn join_lhs_and_rhs<'a>( - lhs: Option<&JoinN>, - rhs_table_number: usize, - rhs_table_reference: &TableReference, - where_clause: &Vec, - constraints: &'a [Constraints], - join_order: &[JoinOrderMember], - maybe_order_target: Option<&OrderTarget>, - access_methods_arena: &'a RefCell>>, -) -> Result { - // The input cardinality for this join is the output cardinality of the previous join. - // For example, in a 2-way join, if the left table has 1000 rows, and the right table will return 2 rows for each of the left table's rows, - // then the output cardinality of the join will be 2000. - let input_cardinality = lhs.map_or(1, |l| l.output_cardinality); - - let best_access_method = find_best_access_method_for_join_order( - rhs_table_number, - rhs_table_reference, - constraints, - &join_order, - maybe_order_target, - input_cardinality as f64, - )?; - - let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); - let cost = lhs_cost + best_access_method.cost; - - let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { - let mut numbers = l.table_numbers.clone(); - numbers.push(rhs_table_number); - numbers - }); - - access_methods_arena.borrow_mut().push(best_access_method); - let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); - best_access_methods.push(access_methods_arena.borrow().len() - 1); - - // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. - let output_cardinality_multiplier = where_clause - .iter() - .filter_map(|term| { - // Skip terms that are not binary comparisons - let Ok(Some((lhs, op, rhs))) = as_binary_components(&term.expr) else { - return None; - }; - // Skip terms that cannot be evaluated at this table's loop level - if !term.should_eval_at_loop(join_order.len() - 1, join_order) { - return None; - } - - // If both lhs and rhs refer to columns from this table, we can't use this constraint - // because we can't use the index to satisfy the condition. - // Examples: - // - WHERE t.x > t.y - // - WHERE t.x + 1 > t.y - 5 - // - WHERE t.x = (t.x) - let Ok(eval_at_left) = determine_where_to_eval_expr(&lhs, join_order) else { - return None; - }; - let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { - return None; - }; - if eval_at_left == EvalAt::Loop(join_order.len() - 1) - && eval_at_right == EvalAt::Loop(join_order.len() - 1) - { - return None; - } - - Some((lhs, op, rhs)) - }) - .filter_map(|(lhs, op, rhs)| { - // Skip terms where neither lhs nor rhs refer to columns from this table - if let ast::Expr::Column { table, column, .. } = lhs { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some((&columns[*column], op)) - } - } else { - None - } - .or_else(|| { - if let ast::Expr::Column { table, column, .. } = rhs { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some((&columns[*column], op)) - } - } else { - None - } - }) - }) - .map(|(column, op)| match op { - ast::Operator::Equals => { - if column.is_rowid_alias || column.primary_key { - 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - } else { - SELECTIVITY_EQ - } - } - ast::Operator::Greater => SELECTIVITY_RANGE, - ast::Operator::GreaterEquals => SELECTIVITY_RANGE, - ast::Operator::Less => SELECTIVITY_RANGE, - ast::Operator::LessEquals => SELECTIVITY_RANGE, - _ => SELECTIVITY_OTHER, - }) - .product::(); - - // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. - // If this table is the rightmost table in the join order, we multiply by the input cardinality, - // which is the output cardinality of the previous tables. - let output_cardinality = (input_cardinality as f64 - * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - * output_cardinality_multiplier) - .ceil() as usize; - - Ok(JoinN { - table_numbers: new_numbers, - best_access_methods, - output_cardinality, - cost, - }) -} - -#[derive(Debug, Clone)] -/// Represents a way to access a table. -pub struct AccessMethod<'a> { - /// The estimated number of page fetches. - /// We are ignoring CPU cost for now. - pub cost: Cost, - pub kind: AccessMethodKind<'a>, -} - -impl<'a> AccessMethod<'a> { - pub fn set_iter_dir(&mut self, new_dir: IterationDirection) { - match &mut self.kind { - AccessMethodKind::Scan { iter_dir, .. } => *iter_dir = new_dir, - AccessMethodKind::Search { iter_dir, .. } => *iter_dir = new_dir, - } - } - - pub fn set_constraints(&mut self, lookup: &ConstraintLookup, constraints: &'a [Constraint]) { - let index = match lookup { - ConstraintLookup::Index(index) => Some(index), - ConstraintLookup::Rowid => None, - ConstraintLookup::EphemeralIndex => panic!("set_constraints called with Lookup::None"), - }; - match (&mut self.kind, constraints.is_empty()) { - ( - AccessMethodKind::Search { - constraints, - index: i, - .. - }, - false, - ) => { - *constraints = constraints; - *i = index.cloned(); - } - (AccessMethodKind::Search { iter_dir, .. }, true) => { - self.kind = AccessMethodKind::Scan { - index: index.cloned(), - iter_dir: *iter_dir, - }; - } - (AccessMethodKind::Scan { iter_dir, .. }, false) => { - self.kind = AccessMethodKind::Search { - index: index.cloned(), - iter_dir: *iter_dir, - constraints, - }; - } - (AccessMethodKind::Scan { index: i, .. }, true) => { - *i = index.cloned(); - } - } - } -} - -#[derive(Debug, Clone)] -/// Represents the kind of access method. -pub enum AccessMethodKind<'a> { - /// A full scan, which can be an index scan or a table scan. - Scan { - index: Option>, - iter_dir: IterationDirection, - }, - /// A search, which can be an index seek or a rowid-based search. - Search { - index: Option>, - iter_dir: IterationDirection, - constraints: &'a [Constraint], - }, -} - -/// Iterator that generates all possible size k bitmasks for a given number of tables. -/// For example, given: 3 tables and k=2, the bitmasks are: -/// - 0b011 (tables 0, 1) -/// - 0b101 (tables 0, 2) -/// - 0b110 (tables 1, 2) -/// -/// This is used in the dynamic programming approach to finding the best way to join a subset of N tables. -struct JoinBitmaskIter { - current: u128, - max_exclusive: u128, -} - -impl JoinBitmaskIter { - fn new(table_number_max_exclusive: usize, how_many: usize) -> Self { - Self { - current: (1 << how_many) - 1, // Start with smallest k-bit number (e.g., 000111 for k=3) - max_exclusive: 1 << table_number_max_exclusive, - } - } -} - -impl Iterator for JoinBitmaskIter { - type Item = TableMask; - - fn next(&mut self) -> Option { - if self.current >= self.max_exclusive { - return None; - } - - let result = TableMask::from_bits(self.current); - - // Gosper's hack: compute next k-bit combination in lexicographic order - let c = self.current & (!self.current + 1); // rightmost set bit - let r = self.current + c; // add it to get a carry - let ones = self.current ^ r; // changed bits - let ones = (ones >> 2) / c; // right-adjust shifted bits - self.current = r | ones; // form the next combination - - Some(result) - } -} - -/// Generate all possible bitmasks of size `how_many` for a given number of tables. -fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> JoinBitmaskIter { - JoinBitmaskIter::new(table_number_max_exclusive, how_many) -} - -/// Check if the plan's row iteration order matches the [OrderTarget]'s column order -fn plan_satisfies_order_target( - plan: &JoinN, - access_methods_arena: &RefCell>, - table_references: &[TableReference], - order_target: &OrderTarget, -) -> bool { - let mut target_col_idx = 0; - for (i, table_no) in plan.table_numbers.iter().enumerate() { - let table_ref = &table_references[*table_no]; - // Check if this table has an access method that provides ordering - let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; - match &access_method.kind { - AccessMethodKind::Scan { - index: None, - iter_dir, - } => { - let rowid_alias_col = table_ref - .table - .columns() - .iter() - .position(|c| c.is_rowid_alias); - let Some(rowid_alias_col) = rowid_alias_col else { - return false; - }; - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order == SortOrder::Desc - }; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - AccessMethodKind::Scan { - index: Some(index), - iter_dir, - } => { - // The index columns must match the order target columns for this table - for index_col in index.columns.iter() { - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == index_col.order - } else { - target_col.order != index_col.order - }; - if target_col.table_no != *table_no - || target_col.column_no != index_col.pos_in_table - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - } - AccessMethodKind::Search { - index, iter_dir, .. - } => { - if let Some(index) = index { - for index_col in index.columns.iter() { - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == index_col.order - } else { - target_col.order != index_col.order - }; - if target_col.table_no != *table_no - || target_col.column_no != index_col.pos_in_table - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - } else { - let rowid_alias_col = table_ref - .table - .columns() - .iter() - .position(|c| c.is_rowid_alias); - let Some(rowid_alias_col) = rowid_alias_col else { - return false; - }; - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order == SortOrder::Desc - }; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - } - } - } - false -} - -/// The result of [compute_best_join_order]. -#[derive(Debug)] -struct BestJoinOrderResult { - /// The best plan overall. - best_plan: JoinN, - /// The best plan for the given order target, if it isn't the overall best. - best_ordered_plan: Option, -} - -/// Compute the best way to join a given set of tables. -/// Returns the best [JoinN] if one exists, otherwise returns None. -fn compute_best_join_order<'a>( - table_references: &[TableReference], - where_clause: &Vec, - maybe_order_target: Option<&OrderTarget>, - constraints: &'a [Constraints], - access_methods_arena: &'a RefCell>>, -) -> Result> { - // Skip work if we have no tables to consider. - if table_references.is_empty() { - return Ok(None); - } - - let num_tables = table_references.len(); - - // Compute naive left-to-right plan to use as pruning threshold - let naive_plan = compute_naive_left_deep_plan( - table_references, - where_clause, - maybe_order_target, - access_methods_arena, - &constraints, - )?; - - // Keep track of both 1. the best plan overall (not considering sorting), and 2. the best ordered plan (which might not be the same). - // We assign Some Cost (tm) to any required sort operation, so the best ordered plan may end up being - // the one we choose, if the cost reduction from avoiding sorting brings it below the cost of the overall best one. - let mut best_ordered_plan: Option = None; - let mut best_plan_is_also_ordered = if let Some(ref order_target) = maybe_order_target { - plan_satisfies_order_target( - &naive_plan, - &access_methods_arena, - table_references, - order_target, - ) - } else { - false - }; - - // If we have one table, then the "naive left-to-right plan" is always the best. - if table_references.len() == 1 { - return Ok(Some(BestJoinOrderResult { - best_plan: naive_plan, - best_ordered_plan: None, - })); - } - let mut best_plan = naive_plan; - - // Reuse a single mutable join order to avoid allocating join orders per permutation. - let mut join_order = Vec::with_capacity(num_tables); - join_order.push(JoinOrderMember { - table_no: 0, - is_outer: false, - }); - - // Keep track of the current best cost so we can short-circuit planning for subplans - // that already exceed the cost of the current best plan. - let cost_upper_bound = best_plan.cost; - let cost_upper_bound_ordered = { - if best_plan_is_also_ordered { - cost_upper_bound - } else { - Cost(f64::MAX) - } - }; - - // Keep track of the best plan for a given subset of tables. - // Consider this example: we have tables a,b,c,d to join. - // if we find that 'b JOIN a' is better than 'a JOIN b', then we don't need to even try - // to do 'a JOIN b JOIN c', because we know 'b JOIN a JOIN c' is going to be better. - // This is due to the commutativity and associativity of inner joins. - let mut best_plan_memo: HashMap = HashMap::new(); - - // Dynamic programming base case: calculate the best way to access each single table, as if - // there were no other tables. - for i in 0..num_tables { - let mut mask = TableMask::new(); - mask.add_table(i); - let table_ref = &table_references[i]; - join_order[0] = JoinOrderMember { - table_no: i, - is_outer: false, - }; - assert!(join_order.len() == 1); - let rel = join_lhs_and_rhs( - None, - i, - table_ref, - where_clause, - &constraints, - &join_order, - maybe_order_target, - access_methods_arena, - )?; - best_plan_memo.insert(mask, rel); - } - join_order.clear(); - - // As mentioned, inner joins are commutative. Outer joins are NOT. - // Example: - // "a LEFT JOIN b" can NOT be reordered as "b LEFT JOIN a". - // If there are outer joins in the plan, ensure correct ordering. - let left_join_illegal_map = { - let left_join_count = table_references - .iter() - .filter(|t| t.join_info.as_ref().map_or(false, |j| j.outer)) - .count(); - if left_join_count == 0 { - None - } else { - // map from rhs table index to lhs table index - let mut left_join_illegal_map: HashMap = - HashMap::with_capacity(left_join_count); - for (i, _) in table_references.iter().enumerate() { - for j in i + 1..table_references.len() { - if table_references[j] - .join_info - .as_ref() - .map_or(false, |j| j.outer) - { - // bitwise OR the masks - if let Some(illegal_lhs) = left_join_illegal_map.get_mut(&i) { - illegal_lhs.add_table(j); - } else { - let mut mask = TableMask::new(); - mask.add_table(j); - left_join_illegal_map.insert(i, mask); - } - } - } - } - Some(left_join_illegal_map) - } - }; - - // Now that we have our single-table base cases, we can start considering join subsets of 2 tables and more. - // Try to join each single table to each other table. - for subset_size in 2..=num_tables { - for mask in generate_join_bitmasks(num_tables, subset_size) { - // Keep track of the best way to join this subset of tables. - // Take the (a,b,c,d) example from above: - // E.g. for "a JOIN b JOIN c", the possibilities are (a,b,c), (a,c,b), (b,a,c) and so on. - // If we find out (b,a,c) is the best way to join these three, then we ONLY need to compute - // the cost of (b,a,c,d) in the final step, because (a,b,c,d) (and all others) are guaranteed to be worse. - let mut best_for_mask: Option = None; - // also keep track of the best plan for this subset that orders the rows in an Interesting Way (tm), - // i.e. allows us to eliminate sort operations downstream. - let (mut best_ordered_for_mask, mut best_for_mask_is_also_ordered) = (None, false); - - // Try to join all subsets (masks) with all other tables. - // In this block, LHS is always (n-1) tables, and RHS is a single table. - for rhs_idx in 0..num_tables { - // If the RHS table isn't a member of this join subset, skip. - if !mask.contains_table(rhs_idx) { - continue; - } - - // If there are no other tables except RHS, skip. - let lhs_mask = mask.without_table(rhs_idx); - if lhs_mask.is_empty() { - continue; - } - - // If this join ordering would violate LEFT JOIN ordering restrictions, skip. - if let Some(illegal_lhs) = left_join_illegal_map - .as_ref() - .and_then(|deps| deps.get(&rhs_idx)) - { - let legal = !lhs_mask.intersects(illegal_lhs); - if !legal { - continue; // Don't allow RHS before its LEFT in LEFT JOIN - } - } - - // If the already cached plan for this subset was too crappy to consider, - // then joining it with RHS won't help. Skip. - let Some(lhs) = best_plan_memo.get(&lhs_mask) else { - continue; - }; - - // Build a JoinOrder out of the table bitmask we are now considering. - for table_no in lhs.table_numbers.iter() { - join_order.push(JoinOrderMember { - table_no: *table_no, - is_outer: table_references[*table_no] - .join_info - .as_ref() - .map_or(false, |j| j.outer), - }); - } - join_order.push(JoinOrderMember { - table_no: rhs_idx, - is_outer: table_references[rhs_idx] - .join_info - .as_ref() - .map_or(false, |j| j.outer), - }); - assert!(join_order.len() == subset_size); - - // Calculate the best way to join LHS with RHS. - let rel = join_lhs_and_rhs( - Some(lhs), - rhs_idx, - &table_references[rhs_idx], - where_clause, - &constraints, - &join_order, - maybe_order_target, - access_methods_arena, - )?; - join_order.clear(); - - // Since cost_upper_bound_ordered is always >= to cost_upper_bound, - // if the cost we calculated for this plan is worse than cost_upper_bound_ordered, - // this join subset is already worse than our best plan for the ENTIRE query, so skip. - if rel.cost >= cost_upper_bound_ordered { - continue; - } - - let satisfies_order_target = if let Some(ref order_target) = maybe_order_target { - plan_satisfies_order_target( - &rel, - &access_methods_arena, - table_references, - order_target, - ) - } else { - false - }; - - // If this plan is worse than our overall best, it might still be the best ordered plan. - if rel.cost >= cost_upper_bound { - // But if it isn't, skip. - if !satisfies_order_target { - continue; - } - let existing_ordered_cost: Cost = best_ordered_for_mask - .as_ref() - .map_or(Cost(f64::MAX), |p: &JoinN| p.cost); - if rel.cost < existing_ordered_cost { - best_ordered_for_mask = Some(rel); - } - } else if best_for_mask.is_none() || rel.cost < best_for_mask.as_ref().unwrap().cost - { - best_for_mask = Some(rel); - best_for_mask_is_also_ordered = satisfies_order_target; - } - } - - if let Some(rel) = best_ordered_for_mask.take() { - let cost = rel.cost; - let has_all_tables = mask.table_count() == num_tables; - if has_all_tables && cost_upper_bound_ordered > cost { - best_ordered_plan = Some(rel); - } - } - - if let Some(rel) = best_for_mask.take() { - let cost = rel.cost; - let has_all_tables = mask.table_count() == num_tables; - if has_all_tables { - if cost_upper_bound > cost { - best_plan = rel; - best_plan_is_also_ordered = best_for_mask_is_also_ordered; - } - } else { - best_plan_memo.insert(mask, rel); - } - } - } - } - - Ok(Some(BestJoinOrderResult { - best_plan, - best_ordered_plan: if best_plan_is_also_ordered { - None - } else { - best_ordered_plan - }, - })) -} - -/// Specialized version of [compute_best_join_order] that just joins tables in the order they are given -/// in the SQL query. This is used as an upper bound for any other plans -- we can give up enumerating -/// permutations if they exceed this cost during enumeration. -fn compute_naive_left_deep_plan<'a>( - table_references: &[TableReference], - where_clause: &Vec, - maybe_order_target: Option<&OrderTarget>, - access_methods_arena: &'a RefCell>>, - constraints: &'a [Constraints], -) -> Result { - let n = table_references.len(); - assert!(n > 0); - - let join_order = table_references - .iter() - .enumerate() - .map(|(i, t)| JoinOrderMember { - table_no: i, - is_outer: t.join_info.as_ref().map_or(false, |j| j.outer), - }) - .collect::>(); - - // Start with first table - let mut best_plan = join_lhs_and_rhs( - None, - 0, - &table_references[0], - where_clause, - constraints, - &join_order[..1], - maybe_order_target, - access_methods_arena, - )?; - - // Add remaining tables one at a time from left to right - for i in 1..n { - best_plan = join_lhs_and_rhs( - Some(&best_plan), - i, - &table_references[i], - where_clause, - constraints, - &join_order[..i + 1], - maybe_order_target, - access_methods_arena, - )?; - } - - Ok(best_plan) -} - -#[derive(Debug, PartialEq, Clone)] -struct ColumnOrder { - table_no: usize, - column_no: usize, - order: SortOrder, -} - -#[derive(Debug, PartialEq, Clone)] -enum EliminatesSort { - GroupBy, - OrderBy, - GroupByAndOrderBy, -} - -#[derive(Debug, PartialEq, Clone)] -pub struct OrderTarget(Vec, EliminatesSort); - -impl OrderTarget { - fn maybe_from_iterator<'a>( - list: impl Iterator + Clone, - eliminates_sort: EliminatesSort, - ) -> Option { - if list.clone().count() == 0 { - return None; - } - if list - .clone() - .any(|(expr, _)| !matches!(expr, ast::Expr::Column { .. })) - { - return None; - } - Some(OrderTarget( - list.map(|(expr, order)| { - let ast::Expr::Column { table, column, .. } = expr else { - unreachable!(); - }; - ColumnOrder { - table_no: *table, - column_no: *column, - order, - } - }) - .collect(), - eliminates_sort, - )) - } -} - -/// Compute an [OrderTarget] for the join optimizer to use. -/// Ideally, a join order is both efficient in joining the tables -/// but also returns the results in an order that minimizes the amount of -/// sorting that needs to be done later (either in GROUP BY, ORDER BY, or both). -/// -/// TODO: this does not currently handle the case where we definitely cannot eliminate -/// the ORDER BY sorter, but we could still eliminate the GROUP BY sorter. -fn compute_order_target( - order_by: &Option>, - group_by: Option<&mut GroupBy>, -) -> Option { - match (order_by, group_by) { - // No ordering demands - we don't care what order the joined result rows are in - (None, None) => None, - // Only ORDER BY - we would like the joined result rows to be in the order specified by the ORDER BY - (Some(order_by), None) => OrderTarget::maybe_from_iterator( - order_by.iter().map(|(expr, order)| (expr, *order)), - EliminatesSort::OrderBy, - ), - // Only GROUP BY - we would like the joined result rows to be in the order specified by the GROUP BY - (None, Some(group_by)) => OrderTarget::maybe_from_iterator( - group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), - EliminatesSort::GroupBy, - ), - // Both ORDER BY and GROUP BY: - // If the GROUP BY does not contain all the expressions in the ORDER BY, - // then we must separately sort the result rows for ORDER BY anyway. - // However, in that case we can use the GROUP BY expressions as the target order for the join, - // so that we don't have to sort twice. - // - // If the GROUP BY contains all the expressions in the ORDER BY, - // then we again can use the GROUP BY expressions as the target order for the join; - // however in this case we must take the ASC/DESC from ORDER BY into account. - (Some(order_by), Some(group_by)) => { - // Does the group by contain all expressions in the order by? - let group_by_contains_all = group_by.exprs.iter().all(|expr| { - order_by - .iter() - .any(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) - }); - // If not, let's try to target an ordering that matches the group by -- we don't care about ASC/DESC - if !group_by_contains_all { - return OrderTarget::maybe_from_iterator( - group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), - EliminatesSort::GroupBy, - ); - } - // If yes, let's try to target an ordering that matches the GROUP BY columns, - // but the ORDER BY orderings. First, we need to reorder the GROUP BY columns to match the ORDER BY columns. - group_by.exprs.sort_by_key(|expr| { - order_by - .iter() - .position(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) - .map_or(usize::MAX, |i| i) - }); - // Iterate over GROUP BY, but take the ORDER BY orderings into account. - OrderTarget::maybe_from_iterator( - group_by - .exprs - .iter() - .zip( - order_by - .iter() - .map(|(_, dir)| dir) - .chain(std::iter::repeat(&SortOrder::Asc)), - ) - .map(|(expr, dir)| (expr, *dir)), - EliminatesSort::GroupByAndOrderBy, - ) - } - } -} - fn use_indexes( table_references: &mut [TableReference], available_indexes: &HashMap>>, @@ -1559,286 +722,6 @@ impl Optimizable for ast::Expr { } } -fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { - match op { - ast::Operator::Equals => ast::Operator::Equals, - ast::Operator::Greater => ast::Operator::Less, - ast::Operator::GreaterEquals => ast::Operator::LessEquals, - ast::Operator::Less => ast::Operator::Greater, - ast::Operator::LessEquals => ast::Operator::GreaterEquals, - _ => panic!("unexpected operator: {:?}", op), - } -} - -/// A simple newtype wrapper over a f64 that represents the cost of an operation. -/// -/// This is used to estimate the cost of scans, seeks, and joins. -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Cost(pub f64); - -impl std::ops::Add for Cost { - type Output = Cost; - - fn add(self, other: Cost) -> Cost { - Cost(self.0 + other.0) - } -} - -impl std::ops::Deref for Cost { - type Target = f64; - - fn deref(&self) -> &f64 { - &self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct IndexInfo { - unique: bool, - column_count: usize, - covering: bool, -} - -const ESTIMATED_HARDCODED_ROWS_PER_TABLE: usize = 1000000; -const ESTIMATED_HARDCODED_ROWS_PER_PAGE: usize = 50; // roughly 80 bytes per 4096 byte page - -fn estimate_page_io_cost(rowcount: f64) -> Cost { - Cost((rowcount as f64 / ESTIMATED_HARDCODED_ROWS_PER_PAGE as f64).ceil()) -} - -/// Estimate the cost of a scan or seek operation. -/// -/// This is a very simple model that estimates the number of pages read -/// based on the number of rows read, ignoring any CPU costs. -fn estimate_cost_for_scan_or_seek( - index_info: Option, - constraints: &[Constraint], - input_cardinality: f64, -) -> Cost { - let Some(index_info) = index_info else { - return estimate_page_io_cost( - input_cardinality * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64, - ); - }; - - let final_constraint_is_range = constraints - .last() - .map_or(false, |c| c.operator != ast::Operator::Equals); - let equalities_count = constraints - .iter() - .take(if final_constraint_is_range { - constraints.len() - 1 - } else { - constraints.len() - }) - .count() as f64; - - let cost_multiplier = match ( - index_info.unique, - index_info.column_count as f64, - equalities_count, - ) { - // no equalities: let's assume range query selectivity is 0.4. if final constraint is not range and there are no equalities, it means full table scan incoming - (_, _, 0.0) => { - if final_constraint_is_range { - 0.4 - } else { - 1.0 - } - } - // on an unique index if we have equalities across all index columns, assume very high selectivity - (true, index_cols, eq_count) if eq_count == index_cols => 0.01, - (false, index_cols, eq_count) if eq_count == index_cols => 0.1, - // some equalities: let's assume each equality has a selectivity of 0.1 and range query selectivity is 0.4 - (_, _, eq_count) => { - let mut multiplier = 1.0; - for _ in 0..(eq_count as usize) { - multiplier *= 0.1; - } - multiplier * if final_constraint_is_range { 4.0 } else { 1.0 } - } - }; - - // little bonus for covering indexes - let covering_multiplier = if index_info.covering { 0.9 } else { 1.0 }; - - estimate_page_io_cost( - cost_multiplier - * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - * input_cardinality - * covering_multiplier, - ) -} - -fn usable_constraints_for_join_order<'a>( - cs: &'a [Constraint], - table_index: usize, - join_order: &[JoinOrderMember], -) -> &'a [Constraint] { - let mut usable_until = 0; - for constraint in cs.iter() { - let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); - if other_side_refers_to_self { - break; - } - let lhs_mask = TableMask::from_iter( - join_order - .iter() - .take(join_order.len() - 1) - .map(|j| j.table_no), - ); - let all_required_tables_are_on_left_side = lhs_mask.contains_all(&constraint.lhs_mask); - if !all_required_tables_are_on_left_side { - break; - } - usable_until += 1; - } - &cs[..usable_until] -} - -/// Return the best [AccessMethod] for a given join order. -/// table_index and table_reference refer to the rightmost table in the join order. -pub fn find_best_access_method_for_join_order<'a>( - table_index: usize, - table_reference: &TableReference, - constraints: &'a [Constraints], - join_order: &[JoinOrderMember], - maybe_order_target: Option<&OrderTarget>, - input_cardinality: f64, -) -> Result> { - let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], input_cardinality); - let mut best_access_method = AccessMethod { - cost: cost_of_full_table_scan, - kind: AccessMethodKind::Scan { - index: None, - iter_dir: IterationDirection::Forwards, - }, - }; - let rowid_column_idx = table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - for csmap in constraints - .iter() - .filter(|csmap| csmap.table_no == table_index) - { - let index_info = match &csmap.lookup { - ConstraintLookup::Index(index) => IndexInfo { - unique: index.unique, - covering: table_reference.index_is_covering(index), - column_count: index.columns.len(), - }, - ConstraintLookup::Rowid => IndexInfo { - unique: true, // rowids are always unique - covering: false, - column_count: 1, - }, - ConstraintLookup::EphemeralIndex => continue, - }; - let usable_constraints = - usable_constraints_for_join_order(&csmap.constraints, table_index, join_order); - let cost = estimate_cost_for_scan_or_seek( - Some(index_info), - &usable_constraints, - input_cardinality, - ); - - let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { - let mut all_same_direction = true; - let mut all_opposite_direction = true; - for i in 0..order_target.0.len().min(index_info.column_count) { - let correct_table = order_target.0[i].table_no == table_index; - let correct_column = { - match &csmap.lookup { - ConstraintLookup::Index(index) => { - index.columns[i].pos_in_table == order_target.0[i].column_no - } - ConstraintLookup::Rowid => { - rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) - } - ConstraintLookup::EphemeralIndex => unreachable!(), - } - }; - if !correct_table || !correct_column { - all_same_direction = false; - all_opposite_direction = false; - break; - } - let correct_order = { - match &csmap.lookup { - ConstraintLookup::Index(index) => { - order_target.0[i].order == index.columns[i].order - } - ConstraintLookup::Rowid => order_target.0[i].order == SortOrder::Asc, - ConstraintLookup::EphemeralIndex => unreachable!(), - } - }; - if correct_order { - all_opposite_direction = false; - } else { - all_same_direction = false; - } - } - if all_same_direction || all_opposite_direction { - Cost(1.0) - } else { - Cost(0.0) - } - } else { - Cost(0.0) - }; - if cost < best_access_method.cost + order_satisfiability_bonus { - best_access_method.cost = cost; - best_access_method.set_constraints(&csmap.lookup, &usable_constraints); - } - } - - let iter_dir = if let Some(order_target) = maybe_order_target { - // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards - let index = match &best_access_method.kind { - AccessMethodKind::Scan { index, .. } => index.as_ref(), - AccessMethodKind::Search { index, .. } => index.as_ref(), - }; - let mut should_use_backwards = true; - let num_cols = index.map_or(1, |i| i.columns.len()); - for i in 0..order_target.0.len().min(num_cols) { - let correct_table = order_target.0[i].table_no == table_index; - let correct_column = { - match index { - Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, - None => { - rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) - } - } - }; - if !correct_table || !correct_column { - should_use_backwards = false; - break; - } - let correct_order = { - match index { - Some(index) => order_target.0[i].order == index.columns[i].order, - None => order_target.0[i].order == SortOrder::Asc, - } - }; - if correct_order { - should_use_backwards = false; - break; - } - } - if should_use_backwards { - IterationDirection::Backwards - } else { - IterationDirection::Forwards - } - } else { - IterationDirection::Forwards - }; - best_access_method.set_iter_dir(iter_dir); - - Ok(best_access_method) -} - fn ephemeral_index_build( table_reference: &TableReference, table_index: usize, @@ -1889,401 +772,6 @@ fn ephemeral_index_build( ephemeral_index } -#[derive(Debug, Clone)] -pub struct Constraint { - /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), - /// since the RHS '10' is the constraining expression and it's part of the second term in the WHERE clause. - where_clause_pos: (usize, BinaryExprSide), - /// The operator of the constraint, e.g. =, >, < - operator: ast::Operator, - /// The position of the index column in the index, e.g. if the index is (a,b,c) and the constraint is on b, then index_column_pos is 1. - /// For Rowid constraints this is always 0. - index_col_pos: usize, - /// The position of the constrained column in the table. - table_col_pos: usize, - /// The sort order of the index column, ASC or DESC. For Rowid constraints this is always ASC. - sort_order: SortOrder, - /// Bitmask of tables that are required to be on the left side of the constrained table, - /// e.g. in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, the lhs_mask contains t2 and t3. - lhs_mask: TableMask, -} - -#[derive(Debug, Clone)] -/// Lookup denotes how a given set of [Constraint]s can be used to access a table. -/// -/// Lookup::Index(index) means that the constraints can be used to access the table using the given index. -/// Lookup::Rowid means that the constraints can be used to access the table using the table's rowid column. -/// Lookup::EphemeralIndex means that the constraints are not useful for accessing the table, -/// but an ephemeral index can be built ad-hoc to use them. -pub enum ConstraintLookup { - Index(Arc), - Rowid, - EphemeralIndex, -} - -#[derive(Debug)] -/// A collection of [Constraint]s for a given (table, index) pair. -pub struct Constraints { - lookup: ConstraintLookup, - table_no: usize, - constraints: Vec, -} - -fn as_binary_components( - expr: &ast::Expr, -) -> Result> { - match unwrap_parens(expr)? { - ast::Expr::Binary(lhs, operator, rhs) - if matches!( - operator, - ast::Operator::Equals - | ast::Operator::Greater - | ast::Operator::Less - | ast::Operator::GreaterEquals - | ast::Operator::LessEquals - ) => - { - Ok(Some((lhs.as_ref(), *operator, rhs.as_ref()))) - } - _ => Ok(None), - } -} - -/// Precompute all potentially usable [Constraints] from a WHERE clause. -/// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. -pub fn constraints_from_where_clause( - where_clause: &[WhereTerm], - table_references: &[TableReference], - available_indexes: &HashMap>>, -) -> Result> { - let mut constraints = Vec::new(); - for (table_no, table_reference) in table_references.iter().enumerate() { - let rowid_alias_column = table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - - let mut cs = Constraints { - lookup: ConstraintLookup::Rowid, - table_no, - constraints: Vec::new(), - }; - let mut cs_ephemeral = Constraints { - lookup: ConstraintLookup::EphemeralIndex, - table_no, - constraints: Vec::new(), - }; - for (i, term) in where_clause.iter().enumerate() { - let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { - continue; - }; - if let Some(outer_join_tbl) = term.from_outer_join { - if outer_join_tbl != table_no { - continue; - } - } - match lhs { - ast::Expr::Column { table, column, .. } => { - if *table == table_no { - if rowid_alias_column.map_or(false, |idx| *column == idx) { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } else { - cs_ephemeral.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: 0, - table_col_pos: *column, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - } - } - ast::Expr::RowId { table, .. } => { - if *table == table_no && rowid_alias_column.is_some() { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - } - _ => {} - }; - match rhs { - ast::Expr::Column { table, column, .. } => { - if *table == table_no { - if rowid_alias_column.map_or(false, |idx| *column == idx) { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } else { - cs_ephemeral.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: 0, - table_col_pos: *column, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - } - ast::Expr::RowId { table, .. } => { - if *table == table_no && rowid_alias_column.is_some() { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - _ => {} - }; - } - // First sort by position, with equalities first within each position - cs.constraints.sort_by(|a, b| { - let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); - if pos_cmp == Ordering::Equal { - // If same position, sort equalities first - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater - } else { - Ordering::Equal - } - } else { - pos_cmp - } - }); - cs_ephemeral.constraints.sort_by(|a, b| { - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater - } else { - Ordering::Equal - } - }); - - // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.index_col_pos); - - // Truncate at first gap in positions - let mut last_pos = 0; - let mut i = 0; - for constraint in cs.constraints.iter() { - if constraint.index_col_pos != last_pos { - if constraint.index_col_pos != last_pos + 1 { - break; - } - last_pos = constraint.index_col_pos; - } - i += 1; - } - cs.constraints.truncate(i); - - // Truncate after the first inequality - if let Some(first_inequality) = cs - .constraints - .iter() - .position(|c| c.operator != ast::Operator::Equals) - { - cs.constraints.truncate(first_inequality + 1); - } - if rowid_alias_column.is_some() { - constraints.push(cs); - } - constraints.push(cs_ephemeral); - - let indexes = available_indexes.get(table_reference.table.get_name()); - if let Some(indexes) = indexes { - for index in indexes { - let mut cs = Constraints { - lookup: ConstraintLookup::Index(index.clone()), - table_no, - constraints: Vec::new(), - }; - for (i, term) in where_clause.iter().enumerate() { - let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { - continue; - }; - if let Some(outer_join_tbl) = term.from_outer_join { - if outer_join_tbl != table_no { - continue; - } - } - if let Some(position_in_index) = - get_column_position_in_index(lhs, table_no, index)? - { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: position_in_index, - table_col_pos: { - let ast::Expr::Column { column, .. } = unwrap_parens(lhs)? else { - crate::bail_parse_error!("expected column in index constraint"); - }; - *column - }, - sort_order: index.columns[position_in_index].order, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - if let Some(position_in_index) = - get_column_position_in_index(rhs, table_no, index)? - { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: position_in_index, - table_col_pos: { - let ast::Expr::Column { column, .. } = unwrap_parens(rhs)? else { - crate::bail_parse_error!("expected column in index constraint"); - }; - *column - }, - sort_order: index.columns[position_in_index].order, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - // First sort by position, with equalities first within each position - cs.constraints.sort_by(|a, b| { - let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); - if pos_cmp == Ordering::Equal { - // If same position, sort equalities first - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater - } else { - Ordering::Equal - } - } else { - pos_cmp - } - }); - - // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.index_col_pos); - - // Truncate at first gap in positions - let mut last_pos = 0; - let mut i = 0; - for constraint in cs.constraints.iter() { - if constraint.index_col_pos != last_pos { - if constraint.index_col_pos != last_pos + 1 { - break; - } - last_pos = constraint.index_col_pos; - } - i += 1; - } - cs.constraints.truncate(i); - - // Truncate after the first inequality - if let Some(first_inequality) = cs - .constraints - .iter() - .position(|c| c.operator != ast::Operator::Equals) - { - cs.constraints.truncate(first_inequality + 1); - } - constraints.push(cs); - } - } - } - - Ok(constraints) -} - -/// Helper enum for [IndexConstraint] to indicate which side of a binary comparison expression is being compared to the index column. -/// For example, if the where clause is "WHERE x = 10" and there's an index on x, -/// the [IndexConstraint] for the where clause term "x = 10" will have a [BinaryExprSide::Rhs] -/// because the right hand side expression "10" is being compared to the index column "x". -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BinaryExprSide { - Lhs, - Rhs, -} - -/// Recursively unwrap parentheses from an expression -/// e.g. (((t.x > 5))) -> t.x > 5 -fn unwrap_parens(expr: T) -> Result -where - T: UnwrapParens, -{ - expr.unwrap_parens() -} - -trait UnwrapParens { - fn unwrap_parens(self) -> Result - where - Self: Sized; -} - -impl UnwrapParens for &ast::Expr { - fn unwrap_parens(self) -> Result { - match self { - ast::Expr::Column { .. } => Ok(self), - ast::Expr::Parenthesized(exprs) => match exprs.len() { - 1 => unwrap_parens(exprs.first().unwrap()), - _ => crate::bail_parse_error!("expected single expression in parentheses"), - }, - _ => Ok(self), - } - } -} - -impl UnwrapParens for ast::Expr { - fn unwrap_parens(self) -> Result { - match self { - ast::Expr::Column { .. } => Ok(self), - ast::Expr::Parenthesized(mut exprs) => match exprs.len() { - 1 => unwrap_parens(exprs.pop().unwrap()), - _ => crate::bail_parse_error!("expected single expression in parentheses"), - }, - _ => Ok(self), - } - } -} - -/// Get the position of a column in an index -/// For example, if there is an index on table T(x,y) then y's position in the index is 1. -fn get_column_position_in_index( - expr: &ast::Expr, - table_index: usize, - index: &Arc, -) -> Result> { - let ast::Expr::Column { table, column, .. } = unwrap_parens(expr)? else { - return Ok(None); - }; - if *table != table_index { - return Ok(None); - } - Ok(index.column_table_pos_to_index_pos(*column)) -} - /// Build a [SeekDef] for a given list of [Constraint]s pub fn build_seek_def_from_constraints( constraints: &[Constraint], @@ -2881,851 +1369,3 @@ impl TakeOwnership for ast::Expr { std::mem::replace(self, ast::Expr::Literal(ast::Literal::Null)) } } - -#[cfg(test)] -mod tests { - use std::rc::Rc; - - use limbo_sqlite3_parser::ast::Operator; - - use super::*; - use crate::{ - schema::{BTreeTable, Column, Table, Type}, - translate::plan::{ColumnUsedMask, JoinInfo}, - translate::planner::TableMask, - }; - - #[test] - fn test_generate_bitmasks() { - let bitmasks = generate_join_bitmasks(4, 2).collect::>(); - assert!(bitmasks.contains(&TableMask(0b110))); // {0,1} -- first bit is always set to 0 so that a Mask with value 0 means "no tables are referenced". - assert!(bitmasks.contains(&TableMask(0b1010))); // {0,2} - assert!(bitmasks.contains(&TableMask(0b1100))); // {1,2} - assert!(bitmasks.contains(&TableMask(0b10010))); // {0,3} - assert!(bitmasks.contains(&TableMask(0b10100))); // {1,3} - assert!(bitmasks.contains(&TableMask(0b11000))); // {2,3} - } - - #[test] - /// Test that [compute_best_join_order] returns None when there are no table references. - fn test_compute_best_join_order_empty() { - let table_references = vec![]; - let available_indexes = HashMap::new(); - let where_clause = vec![]; - - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - let result = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_none()); - } - - #[test] - /// Test that [compute_best_join_order] returns a table scan access method when the where clause is empty. - fn test_compute_best_join_order_single_table_no_indexes() { - let t1 = _create_btree_table("test_table", _create_column_list(&["id"], Type::Integer)); - let table_references = vec![_create_table_reference(t1.clone(), None)]; - let available_indexes = HashMap::new(); - let where_clause = vec![]; - - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - // SELECT * from test_table - // expecting best_best_plan() not to do any work due to empty where clause. - let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap() - .unwrap(); - // Should just be a table scan access method - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); - } - - #[test] - /// Test that [compute_best_join_order] returns a RowidEq access method when the where clause has an EQ constraint on the rowid alias. - fn test_compute_best_join_order_single_table_rowid_eq() { - let t1 = _create_btree_table("test_table", vec![_create_column_rowid_alias("id")]); - let table_references = vec![_create_table_reference(t1.clone(), None)]; - - let where_clause = vec![_create_binary_expr( - _create_column_expr(0, 0, true), // table 0, column 0 (rowid) - ast::Operator::Equals, - _create_numeric_literal("42"), - )]; - - let access_methods_arena = RefCell::new(Vec::new()); - let available_indexes = HashMap::new(); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - // SELECT * FROM test_table WHERE id = 42 - // expecting a RowidEq access method because id is a rowid alias. - let result = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_some()); - let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![0]); - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: None, - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs), - ), - "expected rowid eq access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - ); - } - - #[test] - /// Test that [compute_best_join_order] returns an IndexScan access method when the where clause has an EQ constraint on a primary key. - fn test_compute_best_join_order_single_table_pk_eq() { - let t1 = _create_btree_table( - "test_table", - vec![_create_column_of_type("id", Type::Integer)], - ); - let table_references = vec![_create_table_reference(t1.clone(), None)]; - - let where_clause = vec![_create_binary_expr( - _create_column_expr(0, 0, false), // table 0, column 0 (id) - ast::Operator::Equals, - _create_numeric_literal("42"), - )]; - - let access_methods_arena = RefCell::new(Vec::new()); - let mut available_indexes = HashMap::new(); - let index = Arc::new(Index { - name: "sqlite_autoindex_test_table_1".to_string(), - table_name: "test_table".to_string(), - columns: vec![IndexColumn { - name: "id".to_string(), - order: SortOrder::Asc, - pos_in_table: 0, - }], - unique: true, - ephemeral: false, - root_page: 1, - }); - available_indexes.insert("test_table".to_string(), vec![index]); - - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - // SELECT * FROM test_table WHERE id = 42 - // expecting an IndexScan access method because id is a primary key with an index - let result = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_some()); - let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![0]); - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" - ), - "expected index search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - ); - } - - #[test] - /// Test that [compute_best_join_order] moves the outer table to the inner position when an index can be used on it, but not the original inner table. - fn test_compute_best_join_order_two_tables() { - let t1 = _create_btree_table("table1", _create_column_list(&["id"], Type::Integer)); - let t2 = _create_btree_table("table2", _create_column_list(&["id"], Type::Integer)); - - let mut table_references = vec![ - _create_table_reference(t1.clone(), None), - _create_table_reference( - t2.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - ]; - - let mut available_indexes = HashMap::new(); - // Index on the outer table (table1) - let index1 = Arc::new(Index { - name: "index1".to_string(), - table_name: "table1".to_string(), - columns: vec![IndexColumn { - name: "id".to_string(), - order: SortOrder::Asc, - pos_in_table: 0, - }], - unique: true, - ephemeral: false, - root_page: 1, - }); - available_indexes.insert("table1".to_string(), vec![index1]); - - // SELECT * FROM table1 JOIN table2 WHERE table1.id = table2.id - // expecting table2 to be chosen first due to the index on table1.id - let where_clause = vec![_create_binary_expr( - _create_column_expr(0, 0, false), // table1.id - ast::Operator::Equals, - _create_column_expr(1, 0, false), // table2.id - )]; - - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - let result = compute_best_join_order( - &mut table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_some()); - let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![1, 0]); - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "expected TableScan access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - ); - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind - ); - } - - #[test] - /// Test that [compute_best_join_order] returns a sensible order and plan for three tables, each with indexes. - fn test_compute_best_join_order_three_tables_indexed() { - let table_orders = _create_btree_table( - "orders", - vec![ - _create_column_of_type("id", Type::Integer), - _create_column_of_type("customer_id", Type::Integer), - _create_column_of_type("total", Type::Integer), - ], - ); - let table_customers = _create_btree_table( - "customers", - vec![ - _create_column_of_type("id", Type::Integer), - _create_column_of_type("name", Type::Integer), - ], - ); - let table_order_items = _create_btree_table( - "order_items", - vec![ - _create_column_of_type("id", Type::Integer), - _create_column_of_type("order_id", Type::Integer), - _create_column_of_type("product_id", Type::Integer), - _create_column_of_type("quantity", Type::Integer), - ], - ); - - let table_references = vec![ - _create_table_reference(table_orders.clone(), None), - _create_table_reference( - table_customers.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - _create_table_reference( - table_order_items.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - ]; - - const TABLE_NO_ORDERS: usize = 0; - const TABLE_NO_CUSTOMERS: usize = 1; - const TABLE_NO_ORDER_ITEMS: usize = 2; - - let mut available_indexes = HashMap::new(); - ["orders", "customers", "order_items"] - .iter() - .for_each(|table_name| { - // add primary key index called sqlite_autoindex__1 - let index_name = format!("sqlite_autoindex_{}_1", table_name); - let index = Arc::new(Index { - name: index_name, - table_name: table_name.to_string(), - columns: vec![IndexColumn { - name: "id".to_string(), - order: SortOrder::Asc, - pos_in_table: 0, - }], - unique: true, - ephemeral: false, - root_page: 1, - }); - available_indexes.insert(table_name.to_string(), vec![index]); - }); - let customer_id_idx = Arc::new(Index { - name: "orders_customer_id_idx".to_string(), - table_name: "orders".to_string(), - columns: vec![IndexColumn { - name: "customer_id".to_string(), - order: SortOrder::Asc, - pos_in_table: 1, - }], - unique: false, - ephemeral: false, - root_page: 1, - }); - let order_id_idx = Arc::new(Index { - name: "order_items_order_id_idx".to_string(), - table_name: "order_items".to_string(), - columns: vec![IndexColumn { - name: "order_id".to_string(), - order: SortOrder::Asc, - pos_in_table: 1, - }], - unique: false, - ephemeral: false, - root_page: 1, - }); - - available_indexes - .entry("orders".to_string()) - .and_modify(|v| v.push(customer_id_idx)); - available_indexes - .entry("order_items".to_string()) - .and_modify(|v| v.push(order_id_idx)); - - // SELECT * FROM orders JOIN customers JOIN order_items - // WHERE orders.customer_id = customers.id AND orders.id = order_items.order_id AND customers.id = 42 - // expecting customers to be chosen first due to the index on customers.id and it having a selective filter (=42) - // then orders to be chosen next due to the index on orders.customer_id - // then order_items to be chosen last due to the index on order_items.order_id - let where_clause = vec![ - // orders.customer_id = customers.id - _create_binary_expr( - _create_column_expr(TABLE_NO_ORDERS, 1, false), // orders.customer_id - ast::Operator::Equals, - _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id - ), - // orders.id = order_items.order_id - _create_binary_expr( - _create_column_expr(TABLE_NO_ORDERS, 0, false), // orders.id - ast::Operator::Equals, - _create_column_expr(TABLE_NO_ORDER_ITEMS, 1, false), // order_items.order_id - ), - // customers.id = 42 - _create_binary_expr( - _create_column_expr(TABLE_NO_CUSTOMERS, 0, false), // customers.id - ast::Operator::Equals, - _create_numeric_literal("42"), - ), - ]; - - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - let result = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_some()); - let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - - // Customers (due to =42 filter) -> Orders (due to index on customer_id) -> Order_items (due to index on order_id) - assert_eq!( - best_plan.table_numbers, - vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] - ); - - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_customers_1", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - ); - - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_CUSTOMERS) && index.name == "orders_customer_id_idx", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind - ); - - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_ORDERS) && index.name == "order_items_order_id_idx", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind - ); - } - - struct TestColumn { - name: String, - ty: Type, - is_rowid_alias: bool, - } - - impl Default for TestColumn { - fn default() -> Self { - Self { - name: "a".to_string(), - ty: Type::Integer, - is_rowid_alias: false, - } - } - } - - #[test] - fn test_join_order_three_tables_no_indexes() { - let t1 = _create_btree_table("t1", _create_column_list(&["id", "foo"], Type::Integer)); - let t2 = _create_btree_table("t2", _create_column_list(&["id", "foo"], Type::Integer)); - let t3 = _create_btree_table("t3", _create_column_list(&["id", "foo"], Type::Integer)); - - let mut table_references = vec![ - _create_table_reference(t1.clone(), None), - _create_table_reference( - t2.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - _create_table_reference( - t3.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ), - ]; - - let where_clause = vec![ - // t2.foo = 42 (equality filter, more selective) - _create_binary_expr( - _create_column_expr(1, 1, false), // table 1, column 1 (foo) - ast::Operator::Equals, - _create_numeric_literal("42"), - ), - // t1.foo > 10 (inequality filter, less selective) - _create_binary_expr( - _create_column_expr(0, 1, false), // table 0, column 1 (foo) - ast::Operator::Greater, - _create_numeric_literal("10"), - ), - ]; - - let available_indexes = HashMap::new(); - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( - &mut table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap() - .unwrap(); - - // Verify that t2 is chosen first due to its equality filter - assert_eq!(best_plan.table_numbers[0], 1); - // Verify table scan is used since there are no indexes - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); - // Verify that t1 is chosen next due to its inequality filter - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); - // Verify that t3 is chosen last due to no filters - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); - } - - #[test] - /// Test that [compute_best_join_order] chooses a "fact table" as the outer table, - /// when it has a foreign key to all dimension tables. - fn test_compute_best_join_order_star_schema() { - const NUM_DIM_TABLES: usize = 9; - const FACT_TABLE_IDX: usize = 9; - - // Create fact table with foreign keys to all dimension tables - let mut fact_columns = vec![_create_column_rowid_alias("id")]; - for i in 0..NUM_DIM_TABLES { - fact_columns.push(_create_column_of_type( - &format!("dim{}_id", i), - Type::Integer, - )); - } - let fact_table = _create_btree_table("fact", fact_columns); - - // Create dimension tables, each with an id and value column - let dim_tables: Vec<_> = (0..NUM_DIM_TABLES) - .map(|i| { - _create_btree_table( - &format!("dim{}", i), - vec![ - _create_column_rowid_alias("id"), - _create_column_of_type("value", Type::Integer), - ], - ) - }) - .collect(); - - let mut where_clause = vec![]; - - // Add join conditions between fact and each dimension table - for i in 0..NUM_DIM_TABLES { - where_clause.push(_create_binary_expr( - _create_column_expr(FACT_TABLE_IDX, i + 1, false), // fact.dimX_id - ast::Operator::Equals, - _create_column_expr(i, 0, true), // dimX.id - )); - } - - let table_references = { - let mut refs = vec![_create_table_reference(dim_tables[0].clone(), None)]; - refs.extend(dim_tables.iter().skip(1).map(|t| { - _create_table_reference( - t.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - ) - })); - refs.push(_create_table_reference( - fact_table.clone(), - Some(JoinInfo { - outer: false, - using: None, - }), - )); - refs - }; - - let access_methods_arena = RefCell::new(Vec::new()); - let available_indexes = HashMap::new(); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - let result = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap(); - assert!(result.is_some()); - let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - - // Expected optimal order: fact table as outer, with rowid seeks in any order on each dimension table - // Verify fact table is selected as the outer table as all the other tables can use SeekRowid - assert_eq!( - best_plan.table_numbers[0], FACT_TABLE_IDX, - "First table should be fact (table {}) due to available index, got table {} instead", - FACT_TABLE_IDX, best_plan.table_numbers[0] - ); - - // Verify access methods - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "First table (fact) should use table scan due to column filter" - ); - - for i in 1..best_plan.table_numbers.len() { - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind, - AccessMethodKind::Search { - index: None, - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(FACT_TABLE_IDX) - ), - "Table {} should use Search access method, got {:?}", - i + 1, - &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind - ); - } - } - - #[test] - /// Test that [compute_best_join_order] figures out that the tables form a "linked list" pattern - /// where a column in each table points to an indexed column in the next table, - /// and chooses the best order based on that. - fn test_compute_best_join_order_linked_list() { - const NUM_TABLES: usize = 5; - - // Create tables t1 -> t2 -> t3 -> t4 -> t5 where there is a foreign key from each table to the next - let mut tables = Vec::with_capacity(NUM_TABLES); - for i in 0..NUM_TABLES { - let mut columns = vec![_create_column_rowid_alias("id")]; - if i < NUM_TABLES - 1 { - columns.push(_create_column_of_type(&format!("next_id"), Type::Integer)); - } - tables.push(_create_btree_table(&format!("t{}", i + 1), columns)); - } - - let available_indexes = HashMap::new(); - - // Create table references - let table_references: Vec<_> = tables - .iter() - .map(|t| _create_table_reference(t.clone(), None)) - .collect(); - - // Create where clause linking each table to the next - let mut where_clause = Vec::new(); - for i in 0..NUM_TABLES - 1 { - where_clause.push(_create_binary_expr( - _create_column_expr(i, 1, false), // ti.next_id - ast::Operator::Equals, - _create_column_expr(i + 1, 0, true), // t(i+1).id - )); - } - - let access_methods_arena = RefCell::new(Vec::new()); - let constraints = - constraints_from_where_clause(&where_clause, &table_references, &available_indexes) - .unwrap(); - - // Run the optimizer - let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( - &table_references, - &where_clause, - None, - &constraints, - &access_methods_arena, - ) - .unwrap() - .unwrap(); - - // Verify the join order is exactly t1 -> t2 -> t3 -> t4 -> t5 - for i in 0..NUM_TABLES { - assert_eq!( - best_plan.table_numbers[i], i, - "Expected table {} at position {}, got table {} instead", - i, i, best_plan.table_numbers[i] - ); - } - - // Verify access methods: - // - First table should use Table scan - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "First table should use Table scan" - ); - - // all of the rest should use rowid equality - for i in 1..NUM_TABLES { - let method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind; - assert!( - matches!( - method, - AccessMethodKind::Search { - index: None, - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(i-1) - ), - "Table {} should use Search access method, got {:?}", - i + 1, - method - ); - } - } - - fn _create_column(c: &TestColumn) -> Column { - Column { - name: Some(c.name.clone()), - ty: c.ty, - ty_str: c.ty.to_string(), - is_rowid_alias: c.is_rowid_alias, - primary_key: false, - notnull: false, - default: None, - } - } - fn _create_column_of_type(name: &str, ty: Type) -> Column { - _create_column(&TestColumn { - name: name.to_string(), - ty, - is_rowid_alias: false, - }) - } - - fn _create_column_list(names: &[&str], ty: Type) -> Vec { - names - .iter() - .map(|name| _create_column_of_type(name, ty)) - .collect() - } - - fn _create_column_rowid_alias(name: &str) -> Column { - _create_column(&TestColumn { - name: name.to_string(), - ty: Type::Integer, - is_rowid_alias: true, - }) - } - - /// Creates a BTreeTable with the given name and columns - fn _create_btree_table(name: &str, columns: Vec) -> Rc { - Rc::new(BTreeTable { - root_page: 1, // Page number doesn't matter for tests - name: name.to_string(), - primary_key_columns: vec![], - columns, - has_rowid: true, - is_strict: false, - }) - } - - /// Creates a TableReference for a BTreeTable - fn _create_table_reference( - table: Rc, - join_info: Option, - ) -> TableReference { - let name = table.name.clone(); - TableReference { - table: Table::BTree(table), - op: Operation::Scan { - iter_dir: IterationDirection::Forwards, - index: None, - }, - identifier: name, - join_info, - col_used_mask: ColumnUsedMask::new(), - } - } - - /// Creates a column expression - fn _create_column_expr(table: usize, column: usize, is_rowid_alias: bool) -> Expr { - Expr::Column { - database: None, - table, - column, - is_rowid_alias, - } - } - - /// Creates a binary expression for a WHERE clause - fn _create_binary_expr(lhs: Expr, op: Operator, rhs: Expr) -> WhereTerm { - WhereTerm { - expr: Expr::Binary(Box::new(lhs), op, Box::new(rhs)), - from_outer_join: None, - } - } - - /// Creates a numeric literal expression - fn _create_numeric_literal(value: &str) -> Expr { - Expr::Literal(ast::Literal::Numeric(value.to_string())) - } -} diff --git a/core/translate/optimizer/order.rs b/core/translate/optimizer/order.rs new file mode 100644 index 000000000..799e38233 --- /dev/null +++ b/core/translate/optimizer/order.rs @@ -0,0 +1,254 @@ +use std::cell::RefCell; + +use limbo_sqlite3_parser::ast::{self, SortOrder}; + +use crate::{ + translate::plan::{GroupBy, IterationDirection, TableReference}, + util::exprs_are_equivalent, +}; + +use super::{ + access_method::{AccessMethod, AccessMethodKind}, + join::JoinN, +}; + +#[derive(Debug, PartialEq, Clone)] +pub struct ColumnOrder { + pub table_no: usize, + pub column_no: usize, + pub order: SortOrder, +} + +#[derive(Debug, PartialEq, Clone)] +pub enum EliminatesSort { + GroupBy, + OrderBy, + GroupByAndOrderBy, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct OrderTarget(pub Vec, pub EliminatesSort); + +impl OrderTarget { + fn maybe_from_iterator<'a>( + list: impl Iterator + Clone, + eliminates_sort: EliminatesSort, + ) -> Option { + if list.clone().count() == 0 { + return None; + } + if list + .clone() + .any(|(expr, _)| !matches!(expr, ast::Expr::Column { .. })) + { + return None; + } + Some(OrderTarget( + list.map(|(expr, order)| { + let ast::Expr::Column { table, column, .. } = expr else { + unreachable!(); + }; + ColumnOrder { + table_no: *table, + column_no: *column, + order, + } + }) + .collect(), + eliminates_sort, + )) + } +} + +/// Compute an [OrderTarget] for the join optimizer to use. +/// Ideally, a join order is both efficient in joining the tables +/// but also returns the results in an order that minimizes the amount of +/// sorting that needs to be done later (either in GROUP BY, ORDER BY, or both). +/// +/// TODO: this does not currently handle the case where we definitely cannot eliminate +/// the ORDER BY sorter, but we could still eliminate the GROUP BY sorter. +pub fn compute_order_target( + order_by: &Option>, + group_by: Option<&mut GroupBy>, +) -> Option { + match (order_by, group_by) { + // No ordering demands - we don't care what order the joined result rows are in + (None, None) => None, + // Only ORDER BY - we would like the joined result rows to be in the order specified by the ORDER BY + (Some(order_by), None) => OrderTarget::maybe_from_iterator( + order_by.iter().map(|(expr, order)| (expr, *order)), + EliminatesSort::OrderBy, + ), + // Only GROUP BY - we would like the joined result rows to be in the order specified by the GROUP BY + (None, Some(group_by)) => OrderTarget::maybe_from_iterator( + group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + EliminatesSort::GroupBy, + ), + // Both ORDER BY and GROUP BY: + // If the GROUP BY does not contain all the expressions in the ORDER BY, + // then we must separately sort the result rows for ORDER BY anyway. + // However, in that case we can use the GROUP BY expressions as the target order for the join, + // so that we don't have to sort twice. + // + // If the GROUP BY contains all the expressions in the ORDER BY, + // then we again can use the GROUP BY expressions as the target order for the join; + // however in this case we must take the ASC/DESC from ORDER BY into account. + (Some(order_by), Some(group_by)) => { + // Does the group by contain all expressions in the order by? + let group_by_contains_all = group_by.exprs.iter().all(|expr| { + order_by + .iter() + .any(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) + }); + // If not, let's try to target an ordering that matches the group by -- we don't care about ASC/DESC + if !group_by_contains_all { + return OrderTarget::maybe_from_iterator( + group_by.exprs.iter().map(|expr| (expr, SortOrder::Asc)), + EliminatesSort::GroupBy, + ); + } + // If yes, let's try to target an ordering that matches the GROUP BY columns, + // but the ORDER BY orderings. First, we need to reorder the GROUP BY columns to match the ORDER BY columns. + group_by.exprs.sort_by_key(|expr| { + order_by + .iter() + .position(|(order_by_expr, _)| exprs_are_equivalent(expr, order_by_expr)) + .map_or(usize::MAX, |i| i) + }); + // Iterate over GROUP BY, but take the ORDER BY orderings into account. + OrderTarget::maybe_from_iterator( + group_by + .exprs + .iter() + .zip( + order_by + .iter() + .map(|(_, dir)| dir) + .chain(std::iter::repeat(&SortOrder::Asc)), + ) + .map(|(expr, dir)| (expr, *dir)), + EliminatesSort::GroupByAndOrderBy, + ) + } + } +} + +/// Check if the plan's row iteration order matches the [OrderTarget]'s column order +pub fn plan_satisfies_order_target( + plan: &JoinN, + access_methods_arena: &RefCell>, + table_references: &[TableReference], + order_target: &OrderTarget, +) -> bool { + let mut target_col_idx = 0; + for (i, table_no) in plan.table_numbers.iter().enumerate() { + let table_ref = &table_references[*table_no]; + // Check if this table has an access method that provides ordering + let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; + match &access_method.kind { + AccessMethodKind::Scan { + index: None, + iter_dir, + } => { + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + AccessMethodKind::Scan { + index: Some(index), + iter_dir, + } => { + // The index columns must match the order target columns for this table + for index_col in index.columns.iter() { + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == index_col.order + } else { + target_col.order != index_col.order + }; + if target_col.table_no != *table_no + || target_col.column_no != index_col.pos_in_table + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } + AccessMethodKind::Search { + index, iter_dir, .. + } => { + if let Some(index) = index { + for index_col in index.columns.iter() { + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == index_col.order + } else { + target_col.order != index_col.order + }; + if target_col.table_no != *table_no + || target_col.column_no != index_col.pos_in_table + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } else { + let rowid_alias_col = table_ref + .table + .columns() + .iter() + .position(|c| c.is_rowid_alias); + let Some(rowid_alias_col) = rowid_alias_col else { + return false; + }; + let target_col = &order_target.0[target_col_idx]; + let order_matches = if *iter_dir == IterationDirection::Forwards { + target_col.order == SortOrder::Asc + } else { + target_col.order == SortOrder::Desc + }; + if target_col.table_no != *table_no + || target_col.column_no != rowid_alias_col + || !order_matches + { + return false; + } + target_col_idx += 1; + if target_col_idx == order_target.0.len() { + return true; + } + } + } + } + } + false +} From 6aa5b01a7bed632a45f2a8a93734b72a3b88301c Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 01:25:43 +0300 Subject: [PATCH 19/42] Add note about optimizer directory structure --- core/translate/optimizer/OPTIMIZER.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/core/translate/optimizer/OPTIMIZER.md b/core/translate/optimizer/OPTIMIZER.md index cc56e83a5..2106bdc19 100644 --- a/core/translate/optimizer/OPTIMIZER.md +++ b/core/translate/optimizer/OPTIMIZER.md @@ -2,6 +2,27 @@ Query optimization is obviously an important part of any SQL-based database engine. This document is an overview of what we currently do. +## Structure of the optimizer directory + +1. `mod.rs` + - Provides the high-level optimization interface through `optimize_plan()` + +2. `access_method.rs` + - Determines what is the best index to use when joining a table to a set of preceding tables + +3. `constraints.rs` - Manages query constraints: + - Extracts constraints from the WHERE clause + - Determines which constraints are usable with indexes + +4. `cost.rs` + - Calculates the cost of doing a seek vs a scan, for example + +5. `join.rs` + - Implements the System R style dynamic programming join ordering algorithm + +6. `order.rs` + - Determines if sort operations can be eliminated based on the chosen access methods and join order + ## Join reordering and optimal index selection **The goals of query optimization are at least the following:** From c78261618086566c8477a6dc87fabe35fbad5746 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:01:51 +0300 Subject: [PATCH 20/42] Refactor constraints so that WHERE clause is not needed in join reordering phase --- core/translate/optimizer/access_method.rs | 82 +++-- core/translate/optimizer/constraints.rs | 399 ++++++++++------------ core/translate/optimizer/cost.rs | 13 +- core/translate/optimizer/join.rs | 346 +++++++++---------- core/translate/optimizer/mod.rs | 87 +++-- 5 files changed, 431 insertions(+), 496 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index b706d074a..87a8cfd23 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -9,7 +9,7 @@ use crate::{ }; use super::{ - constraints::{usable_constraints_for_join_order, Constraint, ConstraintLookup, Constraints}, + constraints::{usable_constraints_for_join_order, ConstraintRef, TableConstraints}, cost::{estimate_cost_for_scan_or_seek, Cost, IndexInfo}, order::OrderTarget, }; @@ -31,39 +31,38 @@ impl<'a> AccessMethod<'a> { } } - pub fn set_constraints(&mut self, lookup: &ConstraintLookup, constraints: &'a [Constraint]) { - let index = match lookup { - ConstraintLookup::Index(index) => Some(index), - ConstraintLookup::Rowid => None, - ConstraintLookup::EphemeralIndex => panic!("set_constraints called with Lookup::None"), - }; - match (&mut self.kind, constraints.is_empty()) { + pub fn set_constraint_refs( + &mut self, + new_index: Option>, + new_constraint_refs: &'a [ConstraintRef], + ) { + match (&mut self.kind, new_constraint_refs.is_empty()) { ( AccessMethodKind::Search { - constraints, - index: i, + constraint_refs, + index, .. }, false, ) => { - *constraints = constraints; - *i = index.cloned(); + *constraint_refs = new_constraint_refs; + *index = new_index; } (AccessMethodKind::Search { iter_dir, .. }, true) => { self.kind = AccessMethodKind::Scan { - index: index.cloned(), + index: new_index, iter_dir: *iter_dir, }; } (AccessMethodKind::Scan { iter_dir, .. }, false) => { self.kind = AccessMethodKind::Search { - index: index.cloned(), + index: new_index, iter_dir: *iter_dir, - constraints, + constraint_refs: new_constraint_refs, }; } - (AccessMethodKind::Scan { index: i, .. }, true) => { - *i = index.cloned(); + (AccessMethodKind::Scan { index, .. }, true) => { + *index = new_index; } } } @@ -81,7 +80,7 @@ pub enum AccessMethodKind<'a> { Search { index: Option>, iter_dir: IterationDirection, - constraints: &'a [Constraint], + constraint_refs: &'a [ConstraintRef], }, } @@ -90,12 +89,12 @@ pub enum AccessMethodKind<'a> { pub fn find_best_access_method_for_join_order<'a>( table_index: usize, table_reference: &TableReference, - constraints: &'a [Constraints], + table_constraints: &'a TableConstraints, join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, input_cardinality: f64, ) -> Result> { - let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], input_cardinality); + let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], &[], input_cardinality); let mut best_access_method = AccessMethod { cost: cost_of_full_table_scan, kind: AccessMethodKind::Scan { @@ -107,28 +106,29 @@ pub fn find_best_access_method_for_join_order<'a>( .columns() .iter() .position(|c| c.is_rowid_alias); - for csmap in constraints - .iter() - .filter(|csmap| csmap.table_no == table_index) - { - let index_info = match &csmap.lookup { - ConstraintLookup::Index(index) => IndexInfo { + for usage in table_constraints.candidates.iter() { + let index_info = match usage.index.as_ref() { + Some(index) => IndexInfo { unique: index.unique, covering: table_reference.index_is_covering(index), column_count: index.columns.len(), }, - ConstraintLookup::Rowid => IndexInfo { + None => IndexInfo { unique: true, // rowids are always unique covering: false, column_count: 1, }, - ConstraintLookup::EphemeralIndex => continue, }; - let usable_constraints = - usable_constraints_for_join_order(&csmap.constraints, table_index, join_order); + let usable_constraint_refs = usable_constraints_for_join_order( + &table_constraints.constraints, + &usage.refs, + table_index, + join_order, + ); let cost = estimate_cost_for_scan_or_seek( Some(index_info), - &usable_constraints, + &table_constraints.constraints, + &usable_constraint_refs, input_cardinality, ); @@ -138,14 +138,11 @@ pub fn find_best_access_method_for_join_order<'a>( for i in 0..order_target.0.len().min(index_info.column_count) { let correct_table = order_target.0[i].table_no == table_index; let correct_column = { - match &csmap.lookup { - ConstraintLookup::Index(index) => { - index.columns[i].pos_in_table == order_target.0[i].column_no - } - ConstraintLookup::Rowid => { + match &usage.index { + Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, + None => { rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) } - ConstraintLookup::EphemeralIndex => unreachable!(), } }; if !correct_table || !correct_column { @@ -154,12 +151,9 @@ pub fn find_best_access_method_for_join_order<'a>( break; } let correct_order = { - match &csmap.lookup { - ConstraintLookup::Index(index) => { - order_target.0[i].order == index.columns[i].order - } - ConstraintLookup::Rowid => order_target.0[i].order == SortOrder::Asc, - ConstraintLookup::EphemeralIndex => unreachable!(), + match &usage.index { + Some(index) => order_target.0[i].order == index.columns[i].order, + None => order_target.0[i].order == SortOrder::Asc, } }; if correct_order { @@ -178,7 +172,7 @@ pub fn find_best_access_method_for_join_order<'a>( }; if cost < best_access_method.cost + order_satisfiability_bonus { best_access_method.cost = cost; - best_access_method.set_constraints(&csmap.lookup, &usable_constraints); + best_access_method.set_constraint_refs(usage.index.clone(), &usable_constraint_refs); } } diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index b9beb2425..a03ae7c26 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -1,15 +1,18 @@ use std::{cmp::Ordering, collections::HashMap, sync::Arc}; use crate::{ - schema::Index, + schema::{Column, Index}, translate::{ - expr::{as_binary_components, unwrap_parens}, + expr::as_binary_components, plan::{JoinOrderMember, TableReference, WhereTerm}, planner::{table_mask_from_expr, TableMask}, }, Result, }; use limbo_sqlite3_parser::ast::{self, SortOrder}; + +use super::cost::ESTIMATED_HARDCODED_ROWS_PER_TABLE; + #[derive(Debug, Clone)] pub struct Constraint { /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), @@ -17,37 +20,42 @@ pub struct Constraint { pub where_clause_pos: (usize, BinaryExprSide), /// The operator of the constraint, e.g. =, >, < pub operator: ast::Operator, - /// The position of the index column in the index, e.g. if the index is (a,b,c) and the constraint is on b, then index_column_pos is 1. - /// For Rowid constraints this is always 0. - pub index_col_pos: usize, /// The position of the constrained column in the table. pub table_col_pos: usize, - /// The sort order of the index column, ASC or DESC. For Rowid constraints this is always ASC. - pub sort_order: SortOrder, /// Bitmask of tables that are required to be on the left side of the constrained table, /// e.g. in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, the lhs_mask contains t2 and t3. pub lhs_mask: TableMask, + /// The selectivity of the constraint, i.e. the fraction of rows that will match the constraint. + pub selectivity: f64, } #[derive(Debug, Clone)] -/// Lookup denotes how a given set of [Constraint]s can be used to access a table. -/// -/// Lookup::Index(index) means that the constraints can be used to access the table using the given index. -/// Lookup::Rowid means that the constraints can be used to access the table using the table's rowid column. -/// Lookup::EphemeralIndex means that the constraints are not useful for accessing the table, -/// but an ephemeral index can be built ad-hoc to use them. -pub enum ConstraintLookup { - Index(Arc), - Rowid, - EphemeralIndex, +/// A reference to a [Constraint] in a [TableConstraints]. +pub struct ConstraintRef { + /// The position of the constraint in the [TableConstraints::constraints] vector. + pub constraint_vec_pos: usize, + /// The position of the constrained column in the index. Always 0 for rowid indices. + pub index_col_pos: usize, + /// The sort order of the constrained column in the index. Always ascending for rowid indices. + pub sort_order: SortOrder, +} +#[derive(Debug, Clone)] +/// A collection of [ConstraintRef]s for a given index, or if index is None, for the table's rowid index. +pub struct ConstraintUseCandidate { + /// The index that may be used to satisfy the constraints. If none, the table's rowid index is used. + pub index: Option>, + /// References to the constraints that may be used as an access path for the index. + pub refs: Vec, } #[derive(Debug)] -/// A collection of [Constraint]s for a given (table, index) pair. -pub struct Constraints { - pub lookup: ConstraintLookup, +/// A collection of [Constraint]s and their potential [ConstraintUseCandidate]s for a given table. +pub struct TableConstraints { pub table_no: usize, + /// The constraints for the table, i.e. any [WhereTerm]s that reference columns from this table. pub constraints: Vec, + /// Candidates for indexes that may use the constraints to perform a lookup. + pub candidates: Vec, } /// Helper enum for [Constraint] to indicate which side of a binary comparison expression is being compared to the index column. @@ -60,13 +68,40 @@ pub enum BinaryExprSide { Rhs, } +/// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. +const SELECTIVITY_EQ: f64 = 0.01; +/// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. +const SELECTIVITY_RANGE: f64 = 0.4; +/// In lieu of statistics, we estimate that other filters will reduce the output set to 90% of its size. +const SELECTIVITY_OTHER: f64 = 0.9; + +const SELECTIVITY_UNIQUE_EQUALITY: f64 = 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64; + +/// Estimate the selectivity of a constraint based on the operator and the column type. +fn estimate_selectivity(column: &Column, op: ast::Operator) -> f64 { + match op { + ast::Operator::Equals => { + if column.is_rowid_alias || column.primary_key { + SELECTIVITY_UNIQUE_EQUALITY + } else { + SELECTIVITY_EQ + } + } + ast::Operator::Greater => SELECTIVITY_RANGE, + ast::Operator::GreaterEquals => SELECTIVITY_RANGE, + ast::Operator::Less => SELECTIVITY_RANGE, + ast::Operator::LessEquals => SELECTIVITY_RANGE, + _ => SELECTIVITY_OTHER, + } +} + /// Precompute all potentially usable [Constraints] from a WHERE clause. -/// The resulting list of [Constraints] is then used to evaluate the best access methods for various join orders. +/// The resulting list of [TableConstraints] is then used to evaluate the best access methods for various join orders. pub fn constraints_from_where_clause( where_clause: &[WhereTerm], table_references: &[TableReference], available_indexes: &HashMap>>, -) -> Result> { +) -> Result> { let mut constraints = Vec::new(); for (table_no, table_reference) in table_references.iter().enumerate() { let rowid_alias_column = table_reference @@ -74,16 +109,26 @@ pub fn constraints_from_where_clause( .iter() .position(|c| c.is_rowid_alias); - let mut cs = Constraints { - lookup: ConstraintLookup::Rowid, - table_no, - constraints: Vec::new(), - }; - let mut cs_ephemeral = Constraints { - lookup: ConstraintLookup::EphemeralIndex, + let mut cs = TableConstraints { table_no, constraints: Vec::new(), + candidates: available_indexes + .get(table_reference.table.get_name()) + .map_or(Vec::new(), |indexes| { + indexes + .iter() + .map(|index| ConstraintUseCandidate { + index: Some(index.clone()), + refs: Vec::new(), + }) + .collect() + }), }; + cs.candidates.push(ConstraintUseCandidate { + index: None, + refs: Vec::new(), + }); + for (i, term) in where_clause.iter().enumerate() { let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { continue; @@ -96,36 +141,26 @@ pub fn constraints_from_where_clause( match lhs { ast::Expr::Column { table, column, .. } => { if *table == table_no { - if rowid_alias_column.map_or(false, |idx| *column == idx) { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } else { - cs_ephemeral.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: 0, - table_col_pos: *column, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } + let table_column = &table_reference.table.columns()[*column]; + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Rhs), + operator, + table_col_pos: *column, + lhs_mask: table_mask_from_expr(rhs)?, + selectivity: estimate_selectivity(table_column, operator), + }); } } ast::Expr::RowId { table, .. } => { if *table == table_no && rowid_alias_column.is_some() { + let table_column = + &table_reference.table.columns()[rowid_alias_column.unwrap()]; cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Rhs), operator, - index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, lhs_mask: table_mask_from_expr(rhs)?, + selectivity: estimate_selectivity(table_column, operator), }); } } @@ -134,59 +169,34 @@ pub fn constraints_from_where_clause( match rhs { ast::Expr::Column { table, column, .. } => { if *table == table_no { - if rowid_alias_column.map_or(false, |idx| *column == idx) { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: 0, - table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } else { - cs_ephemeral.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: 0, - table_col_pos: *column, - sort_order: SortOrder::Asc, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } + let table_column = &table_reference.table.columns()[*column]; + cs.constraints.push(Constraint { + where_clause_pos: (i, BinaryExprSide::Lhs), + operator: opposite_cmp_op(operator), + table_col_pos: *column, + lhs_mask: table_mask_from_expr(lhs)?, + selectivity: estimate_selectivity(table_column, operator), + }); } } ast::Expr::RowId { table, .. } => { if *table == table_no && rowid_alias_column.is_some() { + let table_column = + &table_reference.table.columns()[rowid_alias_column.unwrap()]; cs.constraints.push(Constraint { where_clause_pos: (i, BinaryExprSide::Lhs), operator: opposite_cmp_op(operator), - index_col_pos: 0, table_col_pos: rowid_alias_column.unwrap(), - sort_order: SortOrder::Asc, lhs_mask: table_mask_from_expr(lhs)?, + selectivity: estimate_selectivity(table_column, operator), }); } } _ => {} }; } - // First sort by position, with equalities first within each position cs.constraints.sort_by(|a, b| { - let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); - if pos_cmp == Ordering::Equal { - // If same position, sort equalities first - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater - } else { - Ordering::Equal - } - } else { - pos_cmp - } - }); - cs_ephemeral.constraints.sort_by(|a, b| { + // sort equalities first so that index keys will be properly constructed if a.operator == ast::Operator::Equals { Ordering::Less } else if b.operator == ast::Operator::Equals { @@ -196,145 +206,96 @@ pub fn constraints_from_where_clause( } }); - // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.index_col_pos); - - // Truncate at first gap in positions - let mut last_pos = 0; - let mut i = 0; - for constraint in cs.constraints.iter() { - if constraint.index_col_pos != last_pos { - if constraint.index_col_pos != last_pos + 1 { - break; - } - last_pos = constraint.index_col_pos; - } - i += 1; - } - cs.constraints.truncate(i); - - // Truncate after the first inequality - if let Some(first_inequality) = cs - .constraints - .iter() - .position(|c| c.operator != ast::Operator::Equals) - { - cs.constraints.truncate(first_inequality + 1); - } - if rowid_alias_column.is_some() { - constraints.push(cs); - } - constraints.push(cs_ephemeral); - - let indexes = available_indexes.get(table_reference.table.get_name()); - if let Some(indexes) = indexes { - for index in indexes { - let mut cs = Constraints { - lookup: ConstraintLookup::Index(index.clone()), - table_no, - constraints: Vec::new(), - }; - for (i, term) in where_clause.iter().enumerate() { - let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { - continue; - }; - if let Some(outer_join_tbl) = term.from_outer_join { - if outer_join_tbl != table_no { - continue; - } - } - if let Some(position_in_index) = - get_column_position_in_index(lhs, table_no, index)? - { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Rhs), - operator, - index_col_pos: position_in_index, - table_col_pos: { - let ast::Expr::Column { column, .. } = unwrap_parens(lhs)? else { - crate::bail_parse_error!("expected column in index constraint"); - }; - *column - }, - sort_order: index.columns[position_in_index].order, - lhs_mask: table_mask_from_expr(rhs)?, - }); - } - if let Some(position_in_index) = - get_column_position_in_index(rhs, table_no, index)? - { - cs.constraints.push(Constraint { - where_clause_pos: (i, BinaryExprSide::Lhs), - operator: opposite_cmp_op(operator), - index_col_pos: position_in_index, - table_col_pos: { - let ast::Expr::Column { column, .. } = unwrap_parens(rhs)? else { - crate::bail_parse_error!("expected column in index constraint"); - }; - *column - }, - sort_order: index.columns[position_in_index].order, - lhs_mask: table_mask_from_expr(lhs)?, - }); - } - } - // First sort by position, with equalities first within each position - cs.constraints.sort_by(|a, b| { - let pos_cmp = a.index_col_pos.cmp(&b.index_col_pos); - if pos_cmp == Ordering::Equal { - // If same position, sort equalities first - if a.operator == ast::Operator::Equals { - Ordering::Less - } else if b.operator == ast::Operator::Equals { - Ordering::Greater + for (i, constraint) in cs.constraints.iter().enumerate() { + if rowid_alias_column.map_or(false, |idx| constraint.table_col_pos == idx) { + let rowid_usage = cs + .candidates + .iter_mut() + .find_map(|usage| { + if usage.index.is_none() { + Some(usage) } else { - Ordering::Equal + None } - } else { - pos_cmp - } + }) + .unwrap(); + rowid_usage.refs.push(ConstraintRef { + constraint_vec_pos: i, + index_col_pos: 0, + sort_order: SortOrder::Asc, }); - - // Deduplicate by position, keeping first occurrence (which will be equality if one exists) - cs.constraints.dedup_by_key(|c| c.index_col_pos); - - // Truncate at first gap in positions - let mut last_pos = 0; - let mut i = 0; - for constraint in cs.constraints.iter() { - if constraint.index_col_pos != last_pos { - if constraint.index_col_pos != last_pos + 1 { - break; - } - last_pos = constraint.index_col_pos; - } - i += 1; - } - cs.constraints.truncate(i); - - // Truncate after the first inequality - if let Some(first_inequality) = cs - .constraints - .iter() - .position(|c| c.operator != ast::Operator::Equals) + } + for index in available_indexes + .get(table_reference.table.get_name()) + .unwrap_or(&Vec::new()) + { + if let Some(position_in_index) = + index.column_table_pos_to_index_pos(constraint.table_col_pos) { - cs.constraints.truncate(first_inequality + 1); + let index_usage = cs + .candidates + .iter_mut() + .find_map(|usage| { + if usage + .index + .as_ref() + .map_or(false, |i| Arc::ptr_eq(index, i)) + { + Some(usage) + } else { + None + } + }) + .unwrap(); + index_usage.refs.push(ConstraintRef { + constraint_vec_pos: i, + index_col_pos: position_in_index, + sort_order: index.columns[position_in_index].order, + }); } - constraints.push(cs); } } + + for usage in cs.candidates.iter_mut() { + // Deduplicate by position, keeping first occurrence (which will be equality if one exists, since the constraints vec is sorted that way) + usage.refs.dedup_by_key(|uref| uref.index_col_pos); + + // Truncate at first gap in positions + let mut last_pos = 0; + let mut i = 0; + for uref in usage.refs.iter() { + if uref.index_col_pos != last_pos { + if uref.index_col_pos != last_pos + 1 { + break; + } + last_pos = uref.index_col_pos; + } + i += 1; + } + usage.refs.truncate(i); + + // Truncate after the first inequality, since the left-prefix rule of indexes requires that all constraints but the last one must be equalities + if let Some(first_inequality) = usage.refs.iter().position(|uref| { + cs.constraints[uref.constraint_vec_pos].operator != ast::Operator::Equals + }) { + usage.refs.truncate(first_inequality + 1); + } + } + constraints.push(cs); } Ok(constraints) } pub fn usable_constraints_for_join_order<'a>( - cs: &'a [Constraint], + constraints: &'a [Constraint], + refs: &'a [ConstraintRef], table_index: usize, join_order: &[JoinOrderMember], -) -> &'a [Constraint] { +) -> &'a [ConstraintRef] { let mut usable_until = 0; - for constraint in cs.iter() { + for uref in refs.iter() { + let constraint = &constraints[uref.constraint_vec_pos]; let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); if other_side_refers_to_self { break; @@ -351,23 +312,7 @@ pub fn usable_constraints_for_join_order<'a>( } usable_until += 1; } - &cs[..usable_until] -} - -/// Get the position of a column in an index -/// For example, if there is an index on table T(x,y) then y's position in the index is 1. -fn get_column_position_in_index( - expr: &ast::Expr, - table_index: usize, - index: &Arc, -) -> Result> { - let ast::Expr::Column { table, column, .. } = unwrap_parens(expr)? else { - return Ok(None); - }; - if *table != table_index { - return Ok(None); - } - Ok(index.column_table_pos_to_index_pos(*column)) + &refs[..usable_until] } fn opposite_cmp_op(op: ast::Operator) -> ast::Operator { diff --git a/core/translate/optimizer/cost.rs b/core/translate/optimizer/cost.rs index 5c8142be7..276f216e0 100644 --- a/core/translate/optimizer/cost.rs +++ b/core/translate/optimizer/cost.rs @@ -1,6 +1,6 @@ use limbo_sqlite3_parser::ast; -use super::constraints::Constraint; +use super::constraints::{Constraint, ConstraintRef}; /// A simple newtype wrapper over a f64 that represents the cost of an operation. /// @@ -45,6 +45,7 @@ pub fn estimate_page_io_cost(rowcount: f64) -> Cost { pub fn estimate_cost_for_scan_or_seek( index_info: Option, constraints: &[Constraint], + usable_constraint_refs: &[ConstraintRef], input_cardinality: f64, ) -> Cost { let Some(index_info) = index_info else { @@ -53,15 +54,15 @@ pub fn estimate_cost_for_scan_or_seek( ); }; - let final_constraint_is_range = constraints - .last() - .map_or(false, |c| c.operator != ast::Operator::Equals); + let final_constraint_is_range = usable_constraint_refs.last().map_or(false, |c| { + constraints[c.constraint_vec_pos].operator != ast::Operator::Equals + }); let equalities_count = constraints .iter() .take(if final_constraint_is_range { - constraints.len() - 1 + usable_constraint_refs.len() - 1 } else { - constraints.len() + usable_constraint_refs.len() }) .count() as f64; diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 572ccc0dc..aef6850ed 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -1,20 +1,17 @@ use std::{cell::RefCell, collections::HashMap}; -use limbo_sqlite3_parser::ast; - use crate::{ translate::{ - expr::as_binary_components, optimizer::{cost::Cost, order::plan_satisfies_order_target}, - plan::{EvalAt, JoinOrderMember, TableReference, WhereTerm}, - planner::{determine_where_to_eval_expr, TableMask}, + plan::{JoinOrderMember, TableReference}, + planner::TableMask, }, Result, }; use super::{ access_method::{find_best_access_method_for_join_order, AccessMethod}, - constraints::Constraints, + constraints::TableConstraints, cost::ESTIMATED_HARDCODED_ROWS_PER_TABLE, order::OrderTarget, }; @@ -32,20 +29,12 @@ pub struct JoinN { pub cost: Cost, } -/// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. -const SELECTIVITY_EQ: f64 = 0.01; -/// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. -const SELECTIVITY_RANGE: f64 = 0.4; -/// In lieu of statistics, we estimate that other filters will reduce the output set to 90% of its size. -const SELECTIVITY_OTHER: f64 = 0.9; - /// Join n-1 tables with the n'th table. pub fn join_lhs_and_rhs<'a>( lhs: Option<&JoinN>, rhs_table_number: usize, rhs_table_reference: &TableReference, - where_clause: &Vec, - constraints: &'a [Constraints], + constraints: &'a TableConstraints, join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, access_methods_arena: &'a RefCell>>, @@ -77,78 +66,15 @@ pub fn join_lhs_and_rhs<'a>( let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); best_access_methods.push(access_methods_arena.borrow().len() - 1); - // Estimate based on the WHERE clause terms how much the different filters will reduce the output set. - let output_cardinality_multiplier = where_clause + let lhs_mask = lhs.map_or(TableMask::new(), |l| { + TableMask::from_iter(l.table_numbers.iter().cloned()) + }); + // Output cardinality is reduced by the product of the selectivities of the constraints that can be used with this join order. + let output_cardinality_multiplier = constraints + .constraints .iter() - .filter_map(|term| { - // Skip terms that are not binary comparisons - let Ok(Some((lhs, op, rhs))) = as_binary_components(&term.expr) else { - return None; - }; - // Skip terms that cannot be evaluated at this table's loop level - if !term.should_eval_at_loop(join_order.len() - 1, join_order) { - return None; - } - - // If both lhs and rhs refer to columns from this table, we can't use this constraint - // because we can't use the index to satisfy the condition. - // Examples: - // - WHERE t.x > t.y - // - WHERE t.x + 1 > t.y - 5 - // - WHERE t.x = (t.x) - let Ok(eval_at_left) = determine_where_to_eval_expr(&lhs, join_order) else { - return None; - }; - let Ok(eval_at_right) = determine_where_to_eval_expr(&rhs, join_order) else { - return None; - }; - if eval_at_left == EvalAt::Loop(join_order.len() - 1) - && eval_at_right == EvalAt::Loop(join_order.len() - 1) - { - return None; - } - - Some((lhs, op, rhs)) - }) - .filter_map(|(lhs, op, rhs)| { - // Skip terms where neither lhs nor rhs refer to columns from this table - if let ast::Expr::Column { table, column, .. } = lhs { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some((&columns[*column], op)) - } - } else { - None - } - .or_else(|| { - if let ast::Expr::Column { table, column, .. } = rhs { - if *table != rhs_table_number { - None - } else { - let columns = rhs_table_reference.columns(); - Some((&columns[*column], op)) - } - } else { - None - } - }) - }) - .map(|(column, op)| match op { - ast::Operator::Equals => { - if column.is_rowid_alias || column.primary_key { - 1.0 / ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 - } else { - SELECTIVITY_EQ - } - } - ast::Operator::Greater => SELECTIVITY_RANGE, - ast::Operator::GreaterEquals => SELECTIVITY_RANGE, - ast::Operator::Less => SELECTIVITY_RANGE, - ast::Operator::LessEquals => SELECTIVITY_RANGE, - _ => SELECTIVITY_OTHER, - }) + .filter(|c| lhs_mask.contains_all(&c.lhs_mask)) + .map(|c| c.selectivity) .product::(); // Produce a number of rows estimated to be returned when this table is filtered by the WHERE clause. @@ -180,9 +106,8 @@ pub struct BestJoinOrderResult { /// Returns the best [JoinN] if one exists, otherwise returns None. pub fn compute_best_join_order<'a>( table_references: &[TableReference], - where_clause: &Vec, maybe_order_target: Option<&OrderTarget>, - constraints: &'a [Constraints], + constraints: &'a [TableConstraints], access_methods_arena: &'a RefCell>>, ) -> Result> { // Skip work if we have no tables to consider. @@ -195,7 +120,6 @@ pub fn compute_best_join_order<'a>( // Compute naive left-to-right plan to use as pruning threshold let naive_plan = compute_naive_left_deep_plan( table_references, - where_clause, maybe_order_target, access_methods_arena, &constraints, @@ -265,8 +189,7 @@ pub fn compute_best_join_order<'a>( None, i, table_ref, - where_clause, - &constraints, + &constraints[i], &join_order, maybe_order_target, access_methods_arena, @@ -381,8 +304,7 @@ pub fn compute_best_join_order<'a>( Some(lhs), rhs_idx, &table_references[rhs_idx], - where_clause, - &constraints, + &constraints[rhs_idx], &join_order, maybe_order_target, access_methods_arena, @@ -464,10 +386,9 @@ pub fn compute_best_join_order<'a>( /// permutations if they exceed this cost during enumeration. pub fn compute_naive_left_deep_plan<'a>( table_references: &[TableReference], - where_clause: &Vec, maybe_order_target: Option<&OrderTarget>, access_methods_arena: &'a RefCell>>, - constraints: &'a [Constraints], + constraints: &'a [TableConstraints], ) -> Result { let n = table_references.len(); assert!(n > 0); @@ -486,8 +407,7 @@ pub fn compute_naive_left_deep_plan<'a>( None, 0, &table_references[0], - where_clause, - constraints, + &constraints[0], &join_order[..1], maybe_order_target, access_methods_arena, @@ -499,8 +419,7 @@ pub fn compute_naive_left_deep_plan<'a>( Some(&best_plan), i, &table_references[i], - where_clause, - constraints, + &constraints[i], &join_order[..i + 1], maybe_order_target, access_methods_arena, @@ -561,7 +480,7 @@ fn generate_join_bitmasks(table_number_max_exclusive: usize, how_many: usize) -> mod tests { use std::{rc::Rc, sync::Arc}; - use limbo_sqlite3_parser::ast::{Expr, Operator, SortOrder}; + use limbo_sqlite3_parser::ast::{self, Expr, Operator, SortOrder}; use super::*; use crate::{ @@ -571,7 +490,7 @@ mod tests { access_method::AccessMethodKind, constraints::{constraints_from_where_clause, BinaryExprSide}, }, - plan::{ColumnUsedMask, IterationDirection, JoinInfo, Operation}, + plan::{ColumnUsedMask, IterationDirection, JoinInfo, Operation, WhereTerm}, planner::TableMask, }, }; @@ -595,15 +514,14 @@ mod tests { let where_clause = vec![]; let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); let result = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -619,7 +537,7 @@ mod tests { let where_clause = vec![]; let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); @@ -627,9 +545,8 @@ mod tests { // expecting best_best_plan() not to do any work due to empty where clause. let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap() @@ -656,7 +573,7 @@ mod tests { let access_methods_arena = RefCell::new(Vec::new()); let available_indexes = HashMap::new(); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); @@ -664,9 +581,8 @@ mod tests { // expecting a RowidEq access method because id is a rowid alias. let result = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -679,9 +595,9 @@ mod tests { AccessMethodKind::Search { index: None, iter_dir, - constraints, + constraint_refs, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs), + if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[0].constraints[constraint_refs[0].constraint_vec_pos].where_clause_pos == (0, BinaryExprSide::Rhs), ), "expected rowid eq access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind @@ -719,16 +635,15 @@ mod tests { }); available_indexes.insert("test_table".to_string(), vec![index]); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); // SELECT * FROM test_table WHERE id = 42 // expecting an IndexScan access method because id is a primary key with an index let result = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -741,9 +656,9 @@ mod tests { AccessMethodKind::Search { index: Some(index), iter_dir, - constraints, + constraint_refs, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" + if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[0].constraints[constraint_refs[0].constraint_vec_pos].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" ), "expected index search access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind @@ -767,6 +682,9 @@ mod tests { ), ]; + const TABLE1: usize = 0; + const TABLE2: usize = 1; + let mut available_indexes = HashMap::new(); // Index on the outer table (table1) let index1 = Arc::new(Index { @@ -786,21 +704,20 @@ mod tests { // SELECT * FROM table1 JOIN table2 WHERE table1.id = table2.id // expecting table2 to be chosen first due to the index on table1.id let where_clause = vec![_create_binary_expr( - _create_column_expr(0, 0, false), // table1.id + _create_column_expr(TABLE1, 0, false), // table1.id ast::Operator::Equals, - _create_column_expr(1, 0, false), // table2.id + _create_column_expr(TABLE2, 0, false), // table2.id )]; let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); let result = compute_best_join_order( &mut table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -822,9 +739,9 @@ mod tests { AccessMethodKind::Search { index: Some(index), iter_dir, - constraints, + constraint_refs, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", + if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[TABLE1].constraints[constraint_refs[0].constraint_vec_pos].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", ), "expected Search access method, got {:?}", access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind @@ -960,15 +877,14 @@ mod tests { ]; let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); let result = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -981,46 +897,98 @@ mod tests { vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] ); + let AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraint_refs, + } = &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + else { + panic!("expected Search access method with index for first table"); + }; + + assert_eq!( + index.name, "sqlite_autoindex_customers_1", + "wrong index name" + ); + assert_eq!( + *iter_dir, + IterationDirection::Forwards, + "wrong iteration direction" + ); + assert_eq!( + constraint_refs.len(), + 1, + "wrong number of constraint references" + ); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.is_empty() && index.name == "sqlite_autoindex_customers_1", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + table_constraints[TABLE_NO_CUSTOMERS].constraints + [constraint_refs[0].constraint_vec_pos] + .lhs_mask + .is_empty(), + "wrong lhs mask: {:?}", + table_constraints[TABLE_NO_CUSTOMERS].constraints + [constraint_refs[0].constraint_vec_pos] + .lhs_mask ); + let AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraint_refs, + } = &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + else { + panic!("expected Search access method with index for second table"); + }; + + assert_eq!(index.name, "orders_customer_id_idx", "wrong index name"); + assert_eq!( + *iter_dir, + IterationDirection::Forwards, + "wrong iteration direction" + ); + assert_eq!( + constraint_refs.len(), + 1, + "wrong number of constraint references" + ); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_CUSTOMERS) && index.name == "orders_customer_id_idx", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + table_constraints[TABLE_NO_ORDERS].constraints[constraint_refs[0].constraint_vec_pos] + .lhs_mask + .contains_table(TABLE_NO_CUSTOMERS), + "wrong lhs mask: {:?}", + table_constraints[TABLE_NO_ORDERS].constraints[constraint_refs[0].constraint_vec_pos] + .lhs_mask ); + let AccessMethodKind::Search { + index: Some(index), + iter_dir, + constraint_refs, + } = &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind + else { + panic!("expected Search access method with index for third table"); + }; + + assert_eq!(index.name, "order_items_order_id_idx", "wrong index name"); + assert_eq!( + *iter_dir, + IterationDirection::Forwards, + "wrong iteration direction" + ); + assert_eq!( + constraint_refs.len(), + 1, + "wrong number of constraint references" + ); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(TABLE_NO_ORDERS) && index.name == "order_items_order_id_idx", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind + table_constraints[TABLE_NO_ORDER_ITEMS].constraints + [constraint_refs[0].constraint_vec_pos] + .lhs_mask + .contains_table(TABLE_NO_ORDERS), + "wrong lhs mask: {:?}", + table_constraints[TABLE_NO_ORDER_ITEMS].constraints + [constraint_refs[0].constraint_vec_pos] + .lhs_mask ); } @@ -1081,15 +1049,14 @@ mod tests { let available_indexes = HashMap::new(); let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &mut table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap() @@ -1181,15 +1148,14 @@ mod tests { let access_methods_arena = RefCell::new(Vec::new()); let available_indexes = HashMap::new(); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); let result = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap(); @@ -1214,20 +1180,33 @@ mod tests { "First table (fact) should use table scan due to column filter" ); - for i in 1..best_plan.table_numbers.len() { + for (i, table_number) in best_plan.table_numbers.iter().enumerate().skip(1) { + let AccessMethodKind::Search { + index: None, + iter_dir, + constraint_refs, + } = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind + else { + panic!("expected Search access method for table {}", table_number); + }; + + assert_eq!( + *iter_dir, + IterationDirection::Forwards, + "wrong iteration direction" + ); + assert_eq!( + constraint_refs.len(), + 1, + "wrong number of constraint references" + ); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind, - AccessMethodKind::Search { - index: None, - iter_dir, - constraints, - } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(FACT_TABLE_IDX) - ), - "Table {} should use Search access method, got {:?}", - i + 1, - &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind + table_constraints[*table_number].constraints[constraint_refs[0].constraint_vec_pos] + .lhs_mask + .contains_table(FACT_TABLE_IDX), + "wrong lhs mask: {:?}", + table_constraints[*table_number].constraints[constraint_refs[0].constraint_vec_pos] + .lhs_mask ); } } @@ -1268,16 +1247,15 @@ mod tests { } let access_methods_arena = RefCell::new(Vec::new()); - let constraints = + let table_constraints = constraints_from_where_clause(&where_clause, &table_references, &available_indexes) .unwrap(); // Run the optimizer let BestJoinOrderResult { best_plan, .. } = compute_best_join_order( &table_references, - &where_clause, None, - &constraints, + &table_constraints, &access_methods_arena, ) .unwrap() @@ -1312,9 +1290,9 @@ mod tests { AccessMethodKind::Search { index: None, iter_dir, - constraints, + constraint_refs, } - if *iter_dir == IterationDirection::Forwards && constraints.len() == 1 && constraints[0].lhs_mask.contains_table(i-1) + if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[i].constraints[constraint_refs[0].constraint_vec_pos].lhs_mask.contains_table(i-1) ), "Table {} should use Search access method, got {:?}", i + 1, diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 9aa29239b..022616c66 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -3,7 +3,7 @@ use std::{cell::RefCell, cmp::Ordering, collections::HashMap, sync::Arc}; use access_method::AccessMethodKind; use constraints::{ constraints_from_where_clause, usable_constraints_for_join_order, BinaryExprSide, Constraint, - ConstraintLookup, + ConstraintRef, }; use cost::Cost; use join::{compute_best_join_order, BestJoinOrderResult}; @@ -128,13 +128,12 @@ fn use_indexes( ) -> Result>> { let access_methods_arena = RefCell::new(Vec::new()); let maybe_order_target = compute_order_target(order_by, group_by.as_mut()); - let constraints = + let constraints_per_table = constraints_from_where_clause(where_clause, table_references, available_indexes)?; let Some(best_join_order_result) = compute_best_join_order( table_references, - where_clause, maybe_order_target.as_ref(), - &constraints, + &constraints_per_table, &access_methods_arena, )? else { @@ -222,29 +221,38 @@ fn use_indexes( Operation::Scan { iter_dir, index } } else { // Try to construct ephemeral index since it's going to be better than a scan for non-outermost tables. - let unindexable_constraints = constraints.iter().find(|c| { - c.table_no == table_number - && matches!(c.lookup, ConstraintLookup::EphemeralIndex) - }); - if let Some(unindexable) = unindexable_constraints { - let usable_constraints = usable_constraints_for_join_order( - &unindexable.constraints, + let table_constraints = constraints_per_table + .iter() + .find(|c| c.table_no == table_number); + if let Some(table_constraints) = table_constraints { + let temp_constraint_refs = (0..table_constraints.constraints.len()) + .map(|i| ConstraintRef { + constraint_vec_pos: i, + index_col_pos: table_constraints.constraints[i].table_col_pos, + sort_order: SortOrder::Asc, + }) + .collect::>(); + let usable_constraint_refs = usable_constraints_for_join_order( + &table_constraints.constraints, + &temp_constraint_refs, table_number, &best_join_order[..=i], ); - if usable_constraints.is_empty() { + if usable_constraint_refs.is_empty() { Operation::Scan { iter_dir, index } } else { let ephemeral_index = ephemeral_index_build( &table_references[table_number], table_number, - &usable_constraints, + &table_constraints.constraints, + &usable_constraint_refs, ); let ephemeral_index = Arc::new(ephemeral_index); Operation::Search(Search::Seek { index: Some(ephemeral_index), seek_def: build_seek_def_from_constraints( - usable_constraints, + &table_constraints.constraints, + &usable_constraint_refs, iter_dir, where_clause, )?, @@ -257,32 +265,37 @@ fn use_indexes( } AccessMethodKind::Search { index, - constraints, + constraint_refs, iter_dir, } => { - assert!(!constraints.is_empty()); - for constraint in constraints.iter() { + assert!(!constraint_refs.is_empty()); + for uref in constraint_refs.iter() { + let constraint = + &constraints_per_table[table_number].constraints[uref.constraint_vec_pos]; to_remove_from_where_clause.push(constraint.where_clause_pos.0); } if let Some(index) = index { Operation::Search(Search::Seek { index: Some(index), seek_def: build_seek_def_from_constraints( - constraints, + &constraints_per_table[table_number].constraints, + &constraint_refs, iter_dir, where_clause, )?, }) } else { assert!( - constraints.len() == 1, + constraint_refs.len() == 1, "expected exactly one constraint for rowid seek, got {:?}", - constraints + constraint_refs ); - match constraints[0].operator { + let constraint = &constraints_per_table[table_number].constraints + [constraint_refs[0].constraint_vec_pos]; + match constraint.operator { ast::Operator::Equals => Operation::Search(Search::RowidEq { cmp_expr: { - let (idx, side) = constraints[0].where_clause_pos; + let (idx, side) = constraint.where_clause_pos; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(&where_clause[idx].expr)? else { @@ -301,7 +314,8 @@ fn use_indexes( _ => Operation::Search(Search::Seek { index: None, seek_def: build_seek_def_from_constraints( - constraints, + &constraints_per_table[table_number].constraints, + &constraint_refs, iter_dir, where_clause, )?, @@ -726,6 +740,7 @@ fn ephemeral_index_build( table_reference: &TableReference, table_index: usize, constraints: &[Constraint], + constraint_refs: &[ConstraintRef], ) -> Index { let mut ephemeral_columns: Vec = table_reference .columns() @@ -741,14 +756,14 @@ fn ephemeral_index_build( .collect(); // sort so that constraints first, then rest in whatever order they were in in the table ephemeral_columns.sort_by(|a, b| { - let a_constraint = constraints + let a_constraint = constraint_refs .iter() .enumerate() - .find(|(_, c)| c.table_col_pos == a.pos_in_table); - let b_constraint = constraints + .find(|(_, c)| constraints[c.constraint_vec_pos].table_col_pos == a.pos_in_table); + let b_constraint = constraint_refs .iter() .enumerate() - .find(|(_, c)| c.table_col_pos == b.pos_in_table); + .find(|(_, c)| constraints[c.constraint_vec_pos].table_col_pos == b.pos_in_table); match (a_constraint, b_constraint) { (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, @@ -775,20 +790,22 @@ fn ephemeral_index_build( /// Build a [SeekDef] for a given list of [Constraint]s pub fn build_seek_def_from_constraints( constraints: &[Constraint], + constraint_refs: &[ConstraintRef], iter_dir: IterationDirection, where_clause: &[WhereTerm], ) -> Result { assert!( - !constraints.is_empty(), - "cannot build seek def from empty list of constraints" + !constraint_refs.is_empty(), + "cannot build seek def from empty list of constraint refs" ); // Extract the key values and operators - let mut key = Vec::with_capacity(constraints.len()); + let mut key = Vec::with_capacity(constraint_refs.len()); - for constraint in constraints { + for uref in constraint_refs { // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) - let (idx, side) = constraint.where_clause_pos; - let where_term = &where_clause[idx]; + let constraint = &constraints[uref.constraint_vec_pos]; + let (where_idx, side) = constraint.where_clause_pos; + let where_term = &where_clause[where_idx]; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { crate::bail_parse_error!("expected binary expression"); }; @@ -797,12 +814,12 @@ pub fn build_seek_def_from_constraints( } else { *rhs }; - key.push((cmp_expr, constraint.sort_order)); + key.push((cmp_expr, uref.sort_order)); } // We know all but potentially the last term is an equality, so we can use the operator of the last term // to form the SeekOp - let op = constraints.last().unwrap().operator; + let op = constraints[constraint_refs.last().unwrap().constraint_vec_pos].operator; let seek_def = build_seek_def(op, iter_dir, key)?; Ok(seek_def) From 15b32f7e5757c50a510dc9d20cf2e9d9023fe152 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:10:55 +0300 Subject: [PATCH 21/42] constraints.rs: more comments --- core/translate/optimizer/constraints.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index a03ae7c26..73e4347a8 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -103,6 +103,8 @@ pub fn constraints_from_where_clause( available_indexes: &HashMap>>, ) -> Result> { let mut constraints = Vec::new(); + + // For each table, collect all the Constraints and all potential index candidates that may use them. for (table_no, table_reference) in table_references.iter().enumerate() { let rowid_alias_column = table_reference .columns() @@ -124,6 +126,7 @@ pub fn constraints_from_where_clause( .collect() }), }; + // Add a candidate for the rowid index, which is always available when the table has a rowid alias. cs.candidates.push(ConstraintUseCandidate { index: None, refs: Vec::new(), @@ -133,11 +136,16 @@ pub fn constraints_from_where_clause( let Some((lhs, operator, rhs)) = as_binary_components(&term.expr)? else { continue; }; + + // Constraints originating from a LEFT JOIN must always be evaluated in that join's RHS table's loop, + // regardless of which tables the constraint references. if let Some(outer_join_tbl) = term.from_outer_join { if outer_join_tbl != table_no { continue; } } + + // 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, .. } => { if *table == table_no { @@ -152,6 +160,9 @@ pub fn constraints_from_where_clause( } } ast::Expr::RowId { table, .. } => { + // A rowid alias column must exist for the 'rowid' keyword to be considered a valid reference. + // This should be a parse error at an earlier stage of the query compilation, but nevertheless, + // we check it here. if *table == table_no && rowid_alias_column.is_some() { let table_column = &table_reference.table.columns()[rowid_alias_column.unwrap()]; @@ -195,8 +206,9 @@ pub fn constraints_from_where_clause( _ => {} }; } + // sort equalities first so that index keys will be properly constructed. + // see e.g.: https://www.solarwinds.com/blog/the-left-prefix-index-rule cs.constraints.sort_by(|a, b| { - // sort equalities first so that index keys will be properly constructed if a.operator == ast::Operator::Equals { Ordering::Less } else if b.operator == ast::Operator::Equals { @@ -206,6 +218,7 @@ pub fn constraints_from_where_clause( } }); + // For each constraint we found, add a reference to it for each index that may be able to use it. for (i, constraint) in cs.constraints.iter().enumerate() { if rowid_alias_column.map_or(false, |idx| constraint.table_col_pos == idx) { let rowid_usage = cs @@ -260,7 +273,7 @@ pub fn constraints_from_where_clause( // Deduplicate by position, keeping first occurrence (which will be equality if one exists, since the constraints vec is sorted that way) usage.refs.dedup_by_key(|uref| uref.index_col_pos); - // Truncate at first gap in positions + // Truncate at first gap in positions -- index columns must be consumed in contiguous order. let mut last_pos = 0; let mut i = 0; for uref in usage.refs.iter() { @@ -274,7 +287,8 @@ pub fn constraints_from_where_clause( } usage.refs.truncate(i); - // Truncate after the first inequality, since the left-prefix rule of indexes requires that all constraints but the last one must be equalities + // Truncate after the first inequality, since the left-prefix rule of indexes requires that all constraints but the last one must be equalities; + // again see: https://www.solarwinds.com/blog/the-left-prefix-index-rule if let Some(first_inequality) = usage.refs.iter().position(|uref| { cs.constraints[uref.constraint_vec_pos].operator != ast::Operator::Equals }) { @@ -287,6 +301,10 @@ pub fn constraints_from_where_clause( Ok(constraints) } +/// Find which [Constraint]s are usable for a given join order. +/// Returns a slice of the references to the constraints that are usable. +/// A constraint is considered usable for a given table if all of the other tables referenced by the constraint +/// are on the left side in the join order relative to the table. pub fn usable_constraints_for_join_order<'a>( constraints: &'a [Constraint], refs: &'a [ConstraintRef], From c18bb3cd14dccae1449af310a1fe3f529432fb1f Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:14:38 +0300 Subject: [PATCH 22/42] rename --- core/translate/optimizer/access_method.rs | 13 +++---- core/translate/optimizer/constraints.rs | 44 +++++++++++------------ core/translate/optimizer/mod.rs | 10 +++--- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index 87a8cfd23..26d997d55 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -106,8 +106,8 @@ pub fn find_best_access_method_for_join_order<'a>( .columns() .iter() .position(|c| c.is_rowid_alias); - for usage in table_constraints.candidates.iter() { - let index_info = match usage.index.as_ref() { + for candidate in table_constraints.candidates.iter() { + let index_info = match candidate.index.as_ref() { Some(index) => IndexInfo { unique: index.unique, covering: table_reference.index_is_covering(index), @@ -121,7 +121,7 @@ pub fn find_best_access_method_for_join_order<'a>( }; let usable_constraint_refs = usable_constraints_for_join_order( &table_constraints.constraints, - &usage.refs, + &candidate.refs, table_index, join_order, ); @@ -138,7 +138,7 @@ pub fn find_best_access_method_for_join_order<'a>( for i in 0..order_target.0.len().min(index_info.column_count) { let correct_table = order_target.0[i].table_no == table_index; let correct_column = { - match &usage.index { + match &candidate.index { Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, None => { rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) @@ -151,7 +151,7 @@ pub fn find_best_access_method_for_join_order<'a>( break; } let correct_order = { - match &usage.index { + match &candidate.index { Some(index) => order_target.0[i].order == index.columns[i].order, None => order_target.0[i].order == SortOrder::Asc, } @@ -172,7 +172,8 @@ pub fn find_best_access_method_for_join_order<'a>( }; if cost < best_access_method.cost + order_satisfiability_bonus { best_access_method.cost = cost; - best_access_method.set_constraint_refs(usage.index.clone(), &usable_constraint_refs); + best_access_method + .set_constraint_refs(candidate.index.clone(), &usable_constraint_refs); } } diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index 73e4347a8..77af11733 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -221,18 +221,18 @@ pub fn constraints_from_where_clause( // For each constraint we found, add a reference to it for each index that may be able to use it. for (i, constraint) in cs.constraints.iter().enumerate() { if rowid_alias_column.map_or(false, |idx| constraint.table_col_pos == idx) { - let rowid_usage = cs + let rowid_candidate = cs .candidates .iter_mut() - .find_map(|usage| { - if usage.index.is_none() { - Some(usage) + .find_map(|candidate| { + if candidate.index.is_none() { + Some(candidate) } else { None } }) .unwrap(); - rowid_usage.refs.push(ConstraintRef { + rowid_candidate.refs.push(ConstraintRef { constraint_vec_pos: i, index_col_pos: 0, sort_order: SortOrder::Asc, @@ -245,22 +245,22 @@ pub fn constraints_from_where_clause( if let Some(position_in_index) = index.column_table_pos_to_index_pos(constraint.table_col_pos) { - let index_usage = cs + let index_candidate = cs .candidates .iter_mut() - .find_map(|usage| { - if usage + .find_map(|candidate| { + if candidate .index .as_ref() .map_or(false, |i| Arc::ptr_eq(index, i)) { - Some(usage) + Some(candidate) } else { None } }) .unwrap(); - index_usage.refs.push(ConstraintRef { + index_candidate.refs.push(ConstraintRef { constraint_vec_pos: i, index_col_pos: position_in_index, sort_order: index.columns[position_in_index].order, @@ -269,30 +269,30 @@ pub fn constraints_from_where_clause( } } - for usage in cs.candidates.iter_mut() { + for candidate in cs.candidates.iter_mut() { // Deduplicate by position, keeping first occurrence (which will be equality if one exists, since the constraints vec is sorted that way) - usage.refs.dedup_by_key(|uref| uref.index_col_pos); + candidate.refs.dedup_by_key(|cref| cref.index_col_pos); // Truncate at first gap in positions -- index columns must be consumed in contiguous order. let mut last_pos = 0; let mut i = 0; - for uref in usage.refs.iter() { - if uref.index_col_pos != last_pos { - if uref.index_col_pos != last_pos + 1 { + for cref in candidate.refs.iter() { + if cref.index_col_pos != last_pos { + if cref.index_col_pos != last_pos + 1 { break; } - last_pos = uref.index_col_pos; + last_pos = cref.index_col_pos; } i += 1; } - usage.refs.truncate(i); + candidate.refs.truncate(i); // Truncate after the first inequality, since the left-prefix rule of indexes requires that all constraints but the last one must be equalities; // again see: https://www.solarwinds.com/blog/the-left-prefix-index-rule - if let Some(first_inequality) = usage.refs.iter().position(|uref| { - cs.constraints[uref.constraint_vec_pos].operator != ast::Operator::Equals + if let Some(first_inequality) = candidate.refs.iter().position(|cref| { + cs.constraints[cref.constraint_vec_pos].operator != ast::Operator::Equals }) { - usage.refs.truncate(first_inequality + 1); + candidate.refs.truncate(first_inequality + 1); } } constraints.push(cs); @@ -312,8 +312,8 @@ pub fn usable_constraints_for_join_order<'a>( join_order: &[JoinOrderMember], ) -> &'a [ConstraintRef] { let mut usable_until = 0; - for uref in refs.iter() { - let constraint = &constraints[uref.constraint_vec_pos]; + for cref in refs.iter() { + let constraint = &constraints[cref.constraint_vec_pos]; let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); if other_side_refers_to_self { break; diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 022616c66..7d764ee2f 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -269,9 +269,9 @@ fn use_indexes( iter_dir, } => { assert!(!constraint_refs.is_empty()); - for uref in constraint_refs.iter() { + for cref in constraint_refs.iter() { let constraint = - &constraints_per_table[table_number].constraints[uref.constraint_vec_pos]; + &constraints_per_table[table_number].constraints[cref.constraint_vec_pos]; to_remove_from_where_clause.push(constraint.where_clause_pos.0); } if let Some(index) = index { @@ -801,9 +801,9 @@ pub fn build_seek_def_from_constraints( // Extract the key values and operators let mut key = Vec::with_capacity(constraint_refs.len()); - for uref in constraint_refs { + for cref in constraint_refs { // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) - let constraint = &constraints[uref.constraint_vec_pos]; + let constraint = &constraints[cref.constraint_vec_pos]; let (where_idx, side) = constraint.where_clause_pos; let where_term = &where_clause[where_idx]; let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { @@ -814,7 +814,7 @@ pub fn build_seek_def_from_constraints( } else { *rhs }; - key.push((cmp_expr, uref.sort_order)); + key.push((cmp_expr, cref.sort_order)); } // We know all but potentially the last term is an equality, so we can use the operator of the last term From 3442e4981d5f087e08abb2b7f1b8c6cd2e4c637e Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:32:22 +0300 Subject: [PATCH 23/42] remove some unnecessary parameters --- core/translate/optimizer/access_method.rs | 25 +++++++++-------------- core/translate/optimizer/constraints.rs | 4 ++-- core/translate/optimizer/join.rs | 15 +++++--------- core/translate/optimizer/mod.rs | 1 - 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index 26d997d55..e8c2ecf62 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -85,15 +85,14 @@ pub enum AccessMethodKind<'a> { } /// Return the best [AccessMethod] for a given join order. -/// table_index and table_reference refer to the rightmost table in the join order. pub fn find_best_access_method_for_join_order<'a>( - table_index: usize, - table_reference: &TableReference, - table_constraints: &'a TableConstraints, + rhs_table: &TableReference, + rhs_constraints: &'a TableConstraints, join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, input_cardinality: f64, ) -> Result> { + let table_no = join_order.last().unwrap().table_no; let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], &[], input_cardinality); let mut best_access_method = AccessMethod { cost: cost_of_full_table_scan, @@ -102,15 +101,12 @@ pub fn find_best_access_method_for_join_order<'a>( iter_dir: IterationDirection::Forwards, }, }; - let rowid_column_idx = table_reference - .columns() - .iter() - .position(|c| c.is_rowid_alias); - for candidate in table_constraints.candidates.iter() { + let rowid_column_idx = rhs_table.columns().iter().position(|c| c.is_rowid_alias); + for candidate in rhs_constraints.candidates.iter() { let index_info = match candidate.index.as_ref() { Some(index) => IndexInfo { unique: index.unique, - covering: table_reference.index_is_covering(index), + covering: rhs_table.index_is_covering(index), column_count: index.columns.len(), }, None => IndexInfo { @@ -120,14 +116,13 @@ pub fn find_best_access_method_for_join_order<'a>( }, }; let usable_constraint_refs = usable_constraints_for_join_order( - &table_constraints.constraints, + &rhs_constraints.constraints, &candidate.refs, - table_index, join_order, ); let cost = estimate_cost_for_scan_or_seek( Some(index_info), - &table_constraints.constraints, + &rhs_constraints.constraints, &usable_constraint_refs, input_cardinality, ); @@ -136,7 +131,7 @@ pub fn find_best_access_method_for_join_order<'a>( let mut all_same_direction = true; let mut all_opposite_direction = true; for i in 0..order_target.0.len().min(index_info.column_count) { - let correct_table = order_target.0[i].table_no == table_index; + let correct_table = order_target.0[i].table_no == table_no; let correct_column = { match &candidate.index { Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, @@ -186,7 +181,7 @@ pub fn find_best_access_method_for_join_order<'a>( let mut should_use_backwards = true; let num_cols = index.map_or(1, |i| i.columns.len()); for i in 0..order_target.0.len().min(num_cols) { - let correct_table = order_target.0[i].table_no == table_index; + let correct_table = order_target.0[i].table_no == table_no; let correct_column = { match index { Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index 77af11733..7ee88656c 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -308,13 +308,13 @@ pub fn constraints_from_where_clause( pub fn usable_constraints_for_join_order<'a>( constraints: &'a [Constraint], refs: &'a [ConstraintRef], - table_index: usize, join_order: &[JoinOrderMember], ) -> &'a [ConstraintRef] { + let table_no = join_order.last().unwrap().table_no; let mut usable_until = 0; for cref in refs.iter() { let constraint = &constraints[cref.constraint_vec_pos]; - let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_index); + let other_side_refers_to_self = constraint.lhs_mask.contains_table(table_no); if other_side_refers_to_self { break; } diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index aef6850ed..a0805a287 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -32,9 +32,8 @@ pub struct JoinN { /// Join n-1 tables with the n'th table. pub fn join_lhs_and_rhs<'a>( lhs: Option<&JoinN>, - rhs_table_number: usize, rhs_table_reference: &TableReference, - constraints: &'a TableConstraints, + rhs_constraints: &'a TableConstraints, join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, access_methods_arena: &'a RefCell>>, @@ -45,9 +44,8 @@ pub fn join_lhs_and_rhs<'a>( let input_cardinality = lhs.map_or(1, |l| l.output_cardinality); let best_access_method = find_best_access_method_for_join_order( - rhs_table_number, rhs_table_reference, - constraints, + rhs_constraints, &join_order, maybe_order_target, input_cardinality as f64, @@ -56,6 +54,7 @@ pub fn join_lhs_and_rhs<'a>( let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); let cost = lhs_cost + best_access_method.cost; + let rhs_table_number = join_order.last().unwrap().table_no; let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { let mut numbers = l.table_numbers.clone(); numbers.push(rhs_table_number); @@ -70,7 +69,7 @@ pub fn join_lhs_and_rhs<'a>( TableMask::from_iter(l.table_numbers.iter().cloned()) }); // Output cardinality is reduced by the product of the selectivities of the constraints that can be used with this join order. - let output_cardinality_multiplier = constraints + let output_cardinality_multiplier = rhs_constraints .constraints .iter() .filter(|c| lhs_mask.contains_all(&c.lhs_mask)) @@ -187,7 +186,6 @@ pub fn compute_best_join_order<'a>( assert!(join_order.len() == 1); let rel = join_lhs_and_rhs( None, - i, table_ref, &constraints[i], &join_order, @@ -302,7 +300,6 @@ pub fn compute_best_join_order<'a>( // Calculate the best way to join LHS with RHS. let rel = join_lhs_and_rhs( Some(lhs), - rhs_idx, &table_references[rhs_idx], &constraints[rhs_idx], &join_order, @@ -405,7 +402,6 @@ pub fn compute_naive_left_deep_plan<'a>( // Start with first table let mut best_plan = join_lhs_and_rhs( None, - 0, &table_references[0], &constraints[0], &join_order[..1], @@ -417,10 +413,9 @@ pub fn compute_naive_left_deep_plan<'a>( for i in 1..n { best_plan = join_lhs_and_rhs( Some(&best_plan), - i, &table_references[i], &constraints[i], - &join_order[..i + 1], + &join_order[..=i], maybe_order_target, access_methods_arena, )?; diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 7d764ee2f..da1543982 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -235,7 +235,6 @@ fn use_indexes( let usable_constraint_refs = usable_constraints_for_join_order( &table_constraints.constraints, &temp_constraint_refs, - table_number, &best_join_order[..=i], ); if usable_constraint_refs.is_empty() { From ff8e187edaede7568c1346b5a6a41d36c3b1c54c Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:35:10 +0300 Subject: [PATCH 24/42] find_best_access_method_for_join_order: comments --- core/translate/optimizer/access_method.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index e8c2ecf62..2844ab319 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -102,6 +102,8 @@ pub fn find_best_access_method_for_join_order<'a>( }, }; let rowid_column_idx = rhs_table.columns().iter().position(|c| c.is_rowid_alias); + + // Estimate cost for each candidate index (including the rowid index) and replace best_access_method if the cost is lower. for candidate in rhs_constraints.candidates.iter() { let index_info = match candidate.index.as_ref() { Some(index) => IndexInfo { @@ -127,7 +129,10 @@ pub fn find_best_access_method_for_join_order<'a>( input_cardinality, ); + // All other things being equal, prefer an access method that satisfies the order target. let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { + // If the index delivers rows in the same direction (or the exact reverse direction) as the order target, then it + // satisfies the order target. let mut all_same_direction = true; let mut all_opposite_direction = true; for i in 0..order_target.0.len().min(index_info.column_count) { From e53ab385d7951549315384c6b13a5ac27f71c84a Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:38:52 +0300 Subject: [PATCH 25/42] order.rs: comments --- core/translate/optimizer/order.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/translate/optimizer/order.rs b/core/translate/optimizer/order.rs index 799e38233..9c2846aae 100644 --- a/core/translate/optimizer/order.rs +++ b/core/translate/optimizer/order.rs @@ -13,6 +13,7 @@ use super::{ }; #[derive(Debug, PartialEq, Clone)] +/// A convenience struct for representing a (table_no, column_no, [SortOrder]) tuple. pub struct ColumnOrder { pub table_no: usize, pub column_no: usize, @@ -20,6 +21,7 @@ pub struct ColumnOrder { } #[derive(Debug, PartialEq, Clone)] +/// If an [OrderTarget] is satisfied, then [EliminatesSort] describes which part of the query no longer requires sorting. pub enum EliminatesSort { GroupBy, OrderBy, @@ -27,6 +29,9 @@ pub enum EliminatesSort { } #[derive(Debug, PartialEq, Clone)] +/// An [OrderTarget] is considered in join optimization and index selection, +/// so that if a given join ordering and its access methods satisfy the [OrderTarget], +/// then the join ordering and its access methods are preferred, all other things being equal. pub struct OrderTarget(pub Vec, pub EliminatesSort); impl OrderTarget { From d8218483a2c56fc67689391e0555c69a28c2bbb4 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:46:29 +0300 Subject: [PATCH 26/42] use_indexes: comments --- core/translate/optimizer/mod.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index da1543982..e35b5dca3 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -119,6 +119,17 @@ fn optimize_subqueries(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { Ok(()) } +/// Optimize the join order and index selection for a query. +/// +/// This function does the following: +/// - Computes a set of [Constraint]s for each table. +/// - Using those constraints, computes the best join order for the list of [TableReference]s +/// and selects the best [crate::translate::optimizer::access_method::AccessMethod] for each table in the join order. +/// - Mutates the [Operation]s in `table_references` to use the selected access methods. +/// - Removes predicates from the `where_clause` that are now redundant due to the selected access methods. +/// - Removes sorting operations if the selected join order and access methods satisfy the [crate::translate::optimizer::order::OrderTarget]. +/// +/// Returns the join order if it was optimized, or None if the default join order was considered best. fn use_indexes( table_references: &mut [TableReference], available_indexes: &HashMap>>, @@ -145,6 +156,8 @@ fn use_indexes( best_ordered_plan, } = best_join_order_result; + // See if best_ordered_plan is better than the overall best_plan if we add a sorting penalty + // to the unordered plan's cost. let best_plan = if let Some(best_ordered_plan) = best_ordered_plan { let best_unordered_plan_cost = best_plan.cost; let best_ordered_plan_cost = best_ordered_plan.cost; @@ -160,6 +173,7 @@ fn use_indexes( best_plan }; + // Eliminate sorting if possible. if let Some(order_target) = maybe_order_target { let satisfies_order_target = plan_satisfies_order_target( &best_plan, @@ -197,6 +211,8 @@ fn use_indexes( }) .collect(); let mut to_remove_from_where_clause = vec![]; + + // Mutate the Operations in `table_references` to use the selected access methods. for (i, join_order_member) in best_join_order.iter().enumerate() { let table_number = join_order_member.table_no; let access_method_kind = access_methods_arena.borrow()[best_access_methods[i]] @@ -220,7 +236,8 @@ fn use_indexes( if index.is_some() || i == 0 { Operation::Scan { iter_dir, index } } else { - // Try to construct ephemeral index since it's going to be better than a scan for non-outermost tables. + // This branch means we have a full table scan for a non-outermost table. + // Try to construct an ephemeral index since it's going to be better than a scan. let table_constraints = constraints_per_table .iter() .find(|c| c.table_no == table_number); From 52b28d3099b3db1dac096bc79e15df216e14a629 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:47:40 +0300 Subject: [PATCH 27/42] rename use_indexes to optimize_table_access --- core/translate/optimizer/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index e35b5dca3..1999c9c29 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -56,7 +56,7 @@ fn optimize_select_plan(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { return Ok(()); } - let best_join_order = use_indexes( + let best_join_order = optimize_table_access( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, @@ -80,7 +80,7 @@ fn optimize_delete_plan(plan: &mut DeletePlan, schema: &Schema) -> Result<()> { return Ok(()); } - let _ = use_indexes( + let _ = optimize_table_access( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, @@ -99,7 +99,7 @@ fn optimize_update_plan(plan: &mut UpdatePlan, schema: &Schema) -> Result<()> { plan.contains_constant_false_condition = true; return Ok(()); } - let _ = use_indexes( + let _ = optimize_table_access( &mut plan.table_references, &schema.indexes, &mut plan.where_clause, @@ -130,7 +130,7 @@ fn optimize_subqueries(plan: &mut SelectPlan, schema: &Schema) -> Result<()> { /// - Removes sorting operations if the selected join order and access methods satisfy the [crate::translate::optimizer::order::OrderTarget]. /// /// Returns the join order if it was optimized, or None if the default join order was considered best. -fn use_indexes( +fn optimize_table_access( table_references: &mut [TableReference], available_indexes: &HashMap>>, where_clause: &mut Vec, From 4f07c808b29fbbc00fa87b0d1537d83e09f838e5 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 15:59:30 +0300 Subject: [PATCH 28/42] Fix bug with constraint ordering introduced by refactor --- core/translate/optimizer/constraints.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index 7ee88656c..f0e5d54bc 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -270,10 +270,11 @@ pub fn constraints_from_where_clause( } for candidate in cs.candidates.iter_mut() { + // Sort by index_col_pos, ascending -- index columns must be consumed in contiguous order. + candidate.refs.sort_by_key(|cref| cref.index_col_pos); // Deduplicate by position, keeping first occurrence (which will be equality if one exists, since the constraints vec is sorted that way) candidate.refs.dedup_by_key(|cref| cref.index_col_pos); - - // Truncate at first gap in positions -- index columns must be consumed in contiguous order. + // Truncate at first gap in positions -- again, index columns must be consumed in contiguous order. let mut last_pos = 0; let mut i = 0; for cref in candidate.refs.iter() { From f12eb25962a6f189de1a6e885e9f970c61eaac1e Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Sat, 10 May 2025 17:37:58 +0300 Subject: [PATCH 29/42] cost.rs: simplify cost estimation --- core/translate/optimizer/cost.rs | 46 +++++--------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/core/translate/optimizer/cost.rs b/core/translate/optimizer/cost.rs index 276f216e0..862592b6a 100644 --- a/core/translate/optimizer/cost.rs +++ b/core/translate/optimizer/cost.rs @@ -1,5 +1,3 @@ -use limbo_sqlite3_parser::ast; - use super::constraints::{Constraint, ConstraintRef}; /// A simple newtype wrapper over a f64 that represents the cost of an operation. @@ -54,49 +52,19 @@ pub fn estimate_cost_for_scan_or_seek( ); }; - let final_constraint_is_range = usable_constraint_refs.last().map_or(false, |c| { - constraints[c.constraint_vec_pos].operator != ast::Operator::Equals - }); - let equalities_count = constraints + let selectivity_multiplier: f64 = usable_constraint_refs .iter() - .take(if final_constraint_is_range { - usable_constraint_refs.len() - 1 - } else { - usable_constraint_refs.len() + .map(|cref| { + let constraint = &constraints[cref.constraint_vec_pos]; + constraint.selectivity }) - .count() as f64; + .product(); - let cost_multiplier = match ( - index_info.unique, - index_info.column_count as f64, - equalities_count, - ) { - // no equalities: let's assume range query selectivity is 0.4. if final constraint is not range and there are no equalities, it means full table scan incoming - (_, _, 0.0) => { - if final_constraint_is_range { - 0.4 - } else { - 1.0 - } - } - // on an unique index if we have equalities across all index columns, assume very high selectivity - (true, index_cols, eq_count) if eq_count == index_cols => 0.01, - (false, index_cols, eq_count) if eq_count == index_cols => 0.1, - // some equalities: let's assume each equality has a selectivity of 0.1 and range query selectivity is 0.4 - (_, _, eq_count) => { - let mut multiplier = 1.0; - for _ in 0..(eq_count as usize) { - multiplier *= 0.1; - } - multiplier * if final_constraint_is_range { 4.0 } else { 1.0 } - } - }; - - // little bonus for covering indexes + // little cheeky bonus for covering indexes let covering_multiplier = if index_info.covering { 0.9 } else { 1.0 }; estimate_page_io_cost( - cost_multiplier + selectivity_multiplier * ESTIMATED_HARDCODED_ROWS_PER_TABLE as f64 * input_cardinality * covering_multiplier, From a90358f669b83ea94c380db40e71b5eebcf1798a Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 11:15:11 +0300 Subject: [PATCH 30/42] TableMask: comments --- core/translate/optimizer/constraints.rs | 2 +- core/translate/optimizer/join.rs | 2 +- core/translate/planner.rs | 36 ++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index f0e5d54bc..b10c6cb8b 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -319,7 +319,7 @@ pub fn usable_constraints_for_join_order<'a>( if other_side_refers_to_self { break; } - let lhs_mask = TableMask::from_iter( + let lhs_mask = TableMask::from_table_number_iter( join_order .iter() .take(join_order.len() - 1) diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index a0805a287..36d11b967 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -66,7 +66,7 @@ pub fn join_lhs_and_rhs<'a>( best_access_methods.push(access_methods_arena.borrow().len() - 1); let lhs_mask = lhs.map_or(TableMask::new(), |l| { - TableMask::from_iter(l.table_numbers.iter().cloned()) + TableMask::from_table_number_iter(l.table_numbers.iter().cloned()) }); // Output cardinality is reduced by the product of the selectivities of the constraints that can be used with this join order. let output_cardinality_multiplier = rhs_constraints diff --git a/core/translate/planner.rs b/core/translate/planner.rs index decf44549..c6326bac2 100644 --- a/core/translate/planner.rs +++ b/core/translate/planner.rs @@ -588,6 +588,24 @@ pub fn determine_where_to_eval_term( return determine_where_to_eval_expr(&term.expr, join_order); } +/// A bitmask representing a set of tables in a query plan. +/// Tables are numbered by their index in [SelectPlan::table_references]. +/// In the bitmask, the first bit is unused so that a mask with all zeros +/// can represent "no tables". +/// +/// E.g. table 0 is represented by bit index 1, table 1 by bit index 2, etc. +/// +/// Usage in Join Optimization +/// +/// In join optimization, [TableMask] is used to: +/// - Generate subsets of tables for dynamic programming in join optimization +/// - Ensure tables are joined in valid orders (e.g., respecting LEFT JOIN order) +/// +/// Usage with constraints (WHERE clause) +/// +/// [TableMask] helps determine: +/// - Which tables are referenced in a constraint +/// - When a constraint can be applied as a join condition (all referenced tables must be on the left side of the table being joined) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TableMask(pub u128); @@ -598,24 +616,33 @@ impl std::ops::BitOrAssign for TableMask { } impl TableMask { + /// Creates a new empty table mask. + /// + /// The initial mask represents an empty set of tables. pub fn new() -> Self { Self(0) } + /// Returns true if the mask represents an empty set of tables. pub fn is_empty(&self) -> bool { self.0 == 0 } + /// Creates a new mask that is the same as this one but without the specified table. pub fn without_table(&self, table_no: usize) -> Self { assert!(table_no < 127, "table_no must be less than 127"); Self(self.0 ^ (1 << (table_no + 1))) } + /// Creates a table mask from raw bits. + /// + /// The bits are shifted left by 1 to maintain the convention that table 0 is at bit 1. pub fn from_bits(bits: u128) -> Self { Self(bits << 1) } - pub fn from_iter(iter: impl Iterator) -> Self { + /// Creates a table mask from an iterator of table numbers. + pub fn from_table_number_iter(iter: impl Iterator) -> Self { iter.fold(Self::new(), |mut mask, table_no| { assert!(table_no < 127, "table_no must be less than 127"); mask.add_table(table_no); @@ -623,29 +650,36 @@ impl TableMask { }) } + /// Adds a table to the mask. pub fn add_table(&mut self, table_no: usize) { assert!(table_no < 127, "table_no must be less than 127"); self.0 |= 1 << (table_no + 1); } + /// Returns true if the mask contains the specified table. pub fn contains_table(&self, table_no: usize) -> bool { assert!(table_no < 127, "table_no must be less than 127"); self.0 & (1 << (table_no + 1)) != 0 } + /// Returns true if this mask contains all tables in the other mask. pub fn contains_all(&self, other: &TableMask) -> bool { self.0 & other.0 == other.0 } + /// Returns the number of tables in the mask. pub fn table_count(&self) -> usize { self.0.count_ones() as usize } + /// Returns true if this mask shares any tables with the other mask. pub fn intersects(&self, other: &TableMask) -> bool { self.0 & other.0 != 0 } } +/// Returns a [TableMask] representing the tables referenced in the given expression. +/// Used in the optimizer for constraint analysis. pub fn table_mask_from_expr(expr: &Expr) -> Result { let mut mask = TableMask::new(); match expr { From 4dde356d9763b544472a0811f5ca45e86460e3d1 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 11:22:00 +0300 Subject: [PATCH 31/42] AccessMethod: simplify --- core/translate/optimizer/access_method.rs | 122 +++++----------------- 1 file changed, 27 insertions(+), 95 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index 2844ab319..165f4fdf2 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -23,51 +23,6 @@ pub struct AccessMethod<'a> { pub kind: AccessMethodKind<'a>, } -impl<'a> AccessMethod<'a> { - pub fn set_iter_dir(&mut self, new_dir: IterationDirection) { - match &mut self.kind { - AccessMethodKind::Scan { iter_dir, .. } => *iter_dir = new_dir, - AccessMethodKind::Search { iter_dir, .. } => *iter_dir = new_dir, - } - } - - pub fn set_constraint_refs( - &mut self, - new_index: Option>, - new_constraint_refs: &'a [ConstraintRef], - ) { - match (&mut self.kind, new_constraint_refs.is_empty()) { - ( - AccessMethodKind::Search { - constraint_refs, - index, - .. - }, - false, - ) => { - *constraint_refs = new_constraint_refs; - *index = new_index; - } - (AccessMethodKind::Search { iter_dir, .. }, true) => { - self.kind = AccessMethodKind::Scan { - index: new_index, - iter_dir: *iter_dir, - }; - } - (AccessMethodKind::Scan { iter_dir, .. }, false) => { - self.kind = AccessMethodKind::Search { - index: new_index, - iter_dir: *iter_dir, - constraint_refs: new_constraint_refs, - }; - } - (AccessMethodKind::Scan { index, .. }, true) => { - *index = new_index; - } - } - } -} - #[derive(Debug, Clone)] /// Represents the kind of access method. pub enum AccessMethodKind<'a> { @@ -130,7 +85,8 @@ pub fn find_best_access_method_for_join_order<'a>( ); // All other things being equal, prefer an access method that satisfies the order target. - let order_satisfiability_bonus = if let Some(order_target) = maybe_order_target { + let (iter_dir, order_satisfiability_bonus) = if let Some(order_target) = maybe_order_target + { // If the index delivers rows in the same direction (or the exact reverse direction) as the order target, then it // satisfies the order target. let mut all_same_direction = true; @@ -163,62 +119,38 @@ pub fn find_best_access_method_for_join_order<'a>( } } if all_same_direction || all_opposite_direction { - Cost(1.0) + ( + if all_same_direction { + IterationDirection::Forwards + } else { + IterationDirection::Backwards + }, + Cost(1.0), + ) } else { - Cost(0.0) + (IterationDirection::Forwards, Cost(0.0)) } } else { - Cost(0.0) + (IterationDirection::Forwards, Cost(0.0)) }; if cost < best_access_method.cost + order_satisfiability_bonus { - best_access_method.cost = cost; - best_access_method - .set_constraint_refs(candidate.index.clone(), &usable_constraint_refs); + best_access_method = AccessMethod { + cost, + kind: if usable_constraint_refs.is_empty() { + AccessMethodKind::Scan { + index: candidate.index.clone(), + iter_dir, + } + } else { + AccessMethodKind::Search { + index: candidate.index.clone(), + iter_dir, + constraint_refs: &usable_constraint_refs, + } + }, + }; } } - let iter_dir = if let Some(order_target) = maybe_order_target { - // if index columns match the order target columns in the exact reverse directions, then we should use IterationDirection::Backwards - let index = match &best_access_method.kind { - AccessMethodKind::Scan { index, .. } => index.as_ref(), - AccessMethodKind::Search { index, .. } => index.as_ref(), - }; - let mut should_use_backwards = true; - let num_cols = index.map_or(1, |i| i.columns.len()); - for i in 0..order_target.0.len().min(num_cols) { - let correct_table = order_target.0[i].table_no == table_no; - let correct_column = { - match index { - Some(index) => index.columns[i].pos_in_table == order_target.0[i].column_no, - None => { - rowid_column_idx.map_or(false, |idx| idx == order_target.0[i].column_no) - } - } - }; - if !correct_table || !correct_column { - should_use_backwards = false; - break; - } - let correct_order = { - match index { - Some(index) => order_target.0[i].order == index.columns[i].order, - None => order_target.0[i].order == SortOrder::Asc, - } - }; - if correct_order { - should_use_backwards = false; - break; - } - } - if should_use_backwards { - IterationDirection::Backwards - } else { - IterationDirection::Forwards - } - } else { - IterationDirection::Forwards - }; - best_access_method.set_iter_dir(iter_dir); - Ok(best_access_method) } From fe628e221a1428d87bb720de951d5c7f8c92fbf7 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 11:45:24 +0300 Subject: [PATCH 32/42] plan_satisfies_order_target(): simplify --- core/translate/optimizer/access_method.rs | 15 +++ core/translate/optimizer/order.rs | 114 +++++++--------------- 2 files changed, 51 insertions(+), 78 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index 165f4fdf2..0da6e2b86 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -23,6 +23,21 @@ pub struct AccessMethod<'a> { pub kind: AccessMethodKind<'a>, } +impl<'a> AccessMethod<'a> { + pub fn index(&self) -> Option<&Index> { + match &self.kind { + AccessMethodKind::Scan { index, .. } => index.as_ref().map(|i| i.as_ref()), + AccessMethodKind::Search { index, .. } => index.as_ref().map(|i| i.as_ref()), + } + } + pub fn iter_dir(&self) -> IterationDirection { + match &self.kind { + AccessMethodKind::Scan { iter_dir, .. } => *iter_dir, + AccessMethodKind::Search { iter_dir, .. } => *iter_dir, + } + } +} + #[derive(Debug, Clone)] /// Represents the kind of access method. pub enum AccessMethodKind<'a> { diff --git a/core/translate/optimizer/order.rs b/core/translate/optimizer/order.rs index 9c2846aae..b6c359915 100644 --- a/core/translate/optimizer/order.rs +++ b/core/translate/optimizer/order.rs @@ -7,10 +7,7 @@ use crate::{ util::exprs_are_equivalent, }; -use super::{ - access_method::{AccessMethod, AccessMethodKind}, - join::JoinN, -}; +use super::{access_method::AccessMethod, join::JoinN}; #[derive(Debug, PartialEq, Clone)] /// A convenience struct for representing a (table_no, column_no, [SortOrder]) tuple. @@ -138,7 +135,8 @@ pub fn compute_order_target( } } -/// Check if the plan's row iteration order matches the [OrderTarget]'s column order +/// Check if the plan's row iteration order matches the [OrderTarget]'s column order. +/// If yes, and this plan is selected, then a sort operation can be eliminated. pub fn plan_satisfies_order_target( plan: &JoinN, access_methods_arena: &RefCell>, @@ -146,15 +144,22 @@ pub fn plan_satisfies_order_target( order_target: &OrderTarget, ) -> bool { let mut target_col_idx = 0; + let num_cols_in_order_target = order_target.0.len(); for (i, table_no) in plan.table_numbers.iter().enumerate() { + let target_col = &order_target.0[target_col_idx]; let table_ref = &table_references[*table_no]; - // Check if this table has an access method that provides ordering + let correct_table = target_col.table_no == *table_no; + if !correct_table { + return false; + } + + // Check if this table has an access method that provides the right ordering. let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; - match &access_method.kind { - AccessMethodKind::Scan { - index: None, - iter_dir, - } => { + let iter_dir = access_method.iter_dir(); + let index = access_method.index(); + match index { + None => { + // No index, so the next required column must be the rowid alias column. let rowid_alias_col = table_ref .table .columns() @@ -163,92 +168,45 @@ pub fn plan_satisfies_order_target( let Some(rowid_alias_col) = rowid_alias_col else { return false; }; - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { + let correct_column = target_col.column_no == rowid_alias_col; + if !correct_column { + return false; + } + + // Btree table rows are always in ascending order of rowid. + let correct_order = if iter_dir == IterationDirection::Forwards { target_col.order == SortOrder::Asc } else { target_col.order == SortOrder::Desc }; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || !order_matches - { + if !correct_order { return false; } target_col_idx += 1; - if target_col_idx == order_target.0.len() { + // All order columns matched. + if target_col_idx == num_cols_in_order_target { return true; } } - AccessMethodKind::Scan { - index: Some(index), - iter_dir, - } => { - // The index columns must match the order target columns for this table + Some(index) => { + // All of the index columns must match the next required columns in the order target. for index_col in index.columns.iter() { let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { + let correct_column = target_col.column_no == index_col.pos_in_table; + if !correct_column { + return false; + } + let correct_order = if iter_dir == IterationDirection::Forwards { target_col.order == index_col.order } else { target_col.order != index_col.order }; - if target_col.table_no != *table_no - || target_col.column_no != index_col.pos_in_table - || !order_matches - { + if !correct_order { return false; } target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - } - AccessMethodKind::Search { - index, iter_dir, .. - } => { - if let Some(index) = index { - for index_col in index.columns.iter() { - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == index_col.order - } else { - target_col.order != index_col.order - }; - if target_col.table_no != *table_no - || target_col.column_no != index_col.pos_in_table - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { - return true; - } - } - } else { - let rowid_alias_col = table_ref - .table - .columns() - .iter() - .position(|c| c.is_rowid_alias); - let Some(rowid_alias_col) = rowid_alias_col else { - return false; - }; - let target_col = &order_target.0[target_col_idx]; - let order_matches = if *iter_dir == IterationDirection::Forwards { - target_col.order == SortOrder::Asc - } else { - target_col.order == SortOrder::Desc - }; - if target_col.table_no != *table_no - || target_col.column_no != rowid_alias_col - || !order_matches - { - return false; - } - target_col_idx += 1; - if target_col_idx == order_target.0.len() { + // All order columns matched. + if target_col_idx == num_cols_in_order_target { return true; } } From 12a2c2b9ad82017ccfc9cd41bb0011a7c72bc0e7 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 12:06:41 +0300 Subject: [PATCH 33/42] Add more documentation to OPTIMIZER.MD --- core/translate/optimizer/OPTIMIZER.md | 56 ++++++++++++++++++--------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/core/translate/optimizer/OPTIMIZER.md b/core/translate/optimizer/OPTIMIZER.md index 2106bdc19..0939fd9e5 100644 --- a/core/translate/optimizer/OPTIMIZER.md +++ b/core/translate/optimizer/OPTIMIZER.md @@ -31,7 +31,7 @@ Query optimization is obviously an important part of any SQL-based database engi 2. Do as little CPU work as possible 3. Retain query correctness. -**The most important ways to achieve #1 and #2 are:** +**The most important ways to achieve no. 1 and no. 2 are:** 1. Choose the optimal access method for each table (e.g. an index or a rowid-based seek, or a full table scan if all else fails). 2. Choose the best or near-best way to reorder the tables in the query so that those optimal access methods can be used. @@ -40,16 +40,39 @@ Query optimization is obviously an important part of any SQL-based database engi ## Limbo's optimizer Limbo's optimizer is an implementation of an extremely traditional [IBM System R](https://www.cs.cmu.edu/~15721-f24/slides/02-Selinger-SystemR-opt.pdf) style optimizer, -i.e. straight from the 70s! The main ideas are: +i.e. straight from the 70s! The DP algorithm is explained below. -1. Find the best (least `cost`) way to access any single table in the query (n=1). Estimate the `output cardinality` (row count) for this table. - - For example, if there is a WHERE clause condition `t1.x = 5` and we have an index on `t1.x`, that index is potentially going to be the best way to access `t1`. Assuming `t1` has `1,000,000` rows, we might estimate that the output cardinality of this will be `10,000` after all the filters on `t1` have been applied. -2. For each result of #1, find the best way to join that result with each other table (n=2). Use the output cardinality of the previous step as the `input cardinality` of this step. -3. For each result of #2, find the best way to join the result of that 2-way join with each other table (n=3) -... -n. Find the best way to join each (n-1)-way join with the remaining table. +### Current high level flow of the optimizer -The intermediate steps of the above algorithm are memoized, and finally the join order and access methods with the least cumulative cost is chosen. +1. **SQL rewriting** + - Rewrite certain SQL expressions to another form (not a lot currently; e.g. rewrite BETWEEN as two comparisons) + - Eliminate constant conditions: e.g. `WHERE 1` is removed, `WHERE 0` short-circuits the whole query because it is trivially false. +2. **Check whether there is an "interesting order"** that we should consider when evaluating indexes and join orders + - Is there a GROUP BY? an ORDER BY? Both? +3. **Convert WHERE clause conjucts to Constraints** + - E.g. in `WHERE t.x = 5`, the expression `5` _constrains_ table `t` to values of `x` that are exactly `5`. + - E.g. in `Where t.x = u.x`, the expression `u.x` constrains `t`, AND `t.x` constrains `u`. + - Per table, each constraint has an estimated _selectivity_ (how much it filters the result set); this affects join order calculations, see the paragraph on _Estimation_ below. + - Per table, constraints are also analyzed for whether one or multiple of them can be used as an index seek key to avoid a full scan. +4. **Compute the best join order using a dynamic programming algorithm:** + - `n` = number of tables considered + - `n=1`: find the lowest _cost_ way to access each single table, given the constraints of the query. Memoize the result. + - `n=2`: for each table found in the `n=1` step, find the best way to join that table with each other table. Memoize the result. + - `n=3`: for each 2-table subset found, find the best way to join that result to each other table. Memoize the result. + - `n=m`: for each `m-1` table subset found, find the best way to join that result to the `m'th` table + - **Use pruning to reduce search space:** + - Compute the literal query order first, and store its _cost_ as an upper threshold + - If at any point a considered join order exceeds the upper threshold, discard that search path since it cannot be better than the current best. + - For example, we have `SELECT * FROM a JOIN b JOIN c JOIN d`. Compute `JOIN(a,b,c,d)` first. If `JOIN (b,a)` is already worse than `JOIN(a,b,c,d)`, we don't have to even try `JOIN(b,a,c)`. + - Also keep track of the best plan per _subset_: + - If we find that `JOIN(b,a,c)` is better than any other permutation of the same tables, e.g. `JOIN(a,b,c)`, then we can discard _ALL_ of the other permutations for that subset. For example, we don't need to consider `JOIN(a,b,c,d)` because we know it's worse than `JOIN(b,a,c,d)`. + - This is possible due to the associativity and commutativity of INNER JOINs. + - Also keep track of the best _ordered plan_ , i.e. one that provides the "interesting order" mentioned above. + - At the end, apply a cost penalty to the best overall plan + - If it is now worse than the best sorted plan, then choose the sorted plan as the best plan for the query. + - This allows us to eliminate a sorting operation. + - If the best overall plan is still best even with the sorting penalty, then keep it. A sorting operation is later applied to sort the rows according to the desired order. +5. **Mutate the plan's `join_order` and `Operation`s to match the computed best plan.** ### Estimation of cost and cardinalities + a note on table statistics @@ -64,12 +87,7 @@ Currently, in the absence of `ANALYZE`, `sqlite_stat1` etc. we assume the follow From the above, we derive the following formula for estimating the cost of joining `t1` with `t2` ``` -JOIN_COST = COST(t1.rows) + t1.rows * COST(t2.rows) + E - -where - COST(rows) = PAGE_IO(rows) - and - E = one-time cost to build ephemeral index if needed (usually 0) +JOIN_COST = PAGE_IO(t1.rows) + t1.rows * PAGE_IO(t2.rows) ``` For example, let's take the query `SELECT * FROM t1 JOIN t2 USING(foo) WHERE t2.foo > 10`. Let's assume the following: @@ -81,22 +99,22 @@ For example, let's take the query `SELECT * FROM t1 JOIN t2 USING(foo) WHERE t2. The best access method for both is a full table scan. The output cardinality of `t1` is the full table, because nothing is filtering it. Hence, the cost of `t1 JOIN t2` becomes: ``` -JOIN_COST = COST(t1.input_rows) + t1.output_rows * COST(t2.input_rows) +JOIN_COST = PAGE_IO(t1.input_rows) + t1.output_rows * PAGE_IO(t2.input_rows) // plugging in the values: -JOIN_COST = COST(6400) + 6400 * COST(8000) +JOIN_COST = PAGE_IO(6400) + 6400 * PAGE_IO(8000) JOIN_COST = 80 + 6400 * 100 = 640080 ``` Now let's consider `t2 JOIN t1`. The best access method for both is still a full scan, but since we can filter on `t2.foo > 10`, its output cardinality decreases. Let's assume only 1/4 of the rows of `t2` match the condition `t2.foo > 10`. Hence, the cost of `t2 join t1` becomes: ``` -JOIN_COST = COST(t2.input_rows) + t2.output_rows * COST(t1.input_rows) +JOIN_COST = PAGE_IO(t2.input_rows) + t2.output_rows * PAGE_IO(t1.input_rows) // plugging in the values: -JOIN_COST = COST(8000) + 1/4 * 8000 * COST(6400) +JOIN_COST = PAGE_IO(8000) + 1/4 * 8000 * PAGE_IO(6400) JOIN_COST = 100 + 2000 * 80 = 160100 ``` From 9d50446ffbc1baf4cb75568602a0efc4fcabff96 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 12:43:40 +0300 Subject: [PATCH 34/42] AccessMethod: simplify - get rid of AccessMethodKind as it can be derived --- core/translate/optimizer/access_method.rs | 75 ++---- core/translate/optimizer/join.rs | 310 +++++++--------------- core/translate/optimizer/mod.rs | 226 ++++++++-------- core/translate/optimizer/order.rs | 4 +- 4 files changed, 238 insertions(+), 377 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index 0da6e2b86..b89cc56f8 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -20,38 +20,34 @@ pub struct AccessMethod<'a> { /// The estimated number of page fetches. /// We are ignoring CPU cost for now. pub cost: Cost, - pub kind: AccessMethodKind<'a>, + /// The direction of iteration for the access method. + /// Typically this is backwards only if it helps satisfy an [OrderTarget]. + pub iter_dir: IterationDirection, + /// The index that is being used, if any. For rowid based searches (and full table scans), this is None. + pub index: Option>, + /// The constraint references that are being used, if any. + /// An empty list of constraint refs means a scan (full table or index); + /// a non-empty list means a search. + pub constraint_refs: &'a [ConstraintRef], } impl<'a> AccessMethod<'a> { - pub fn index(&self) -> Option<&Index> { - match &self.kind { - AccessMethodKind::Scan { index, .. } => index.as_ref().map(|i| i.as_ref()), - AccessMethodKind::Search { index, .. } => index.as_ref().map(|i| i.as_ref()), - } + pub fn is_scan(&self) -> bool { + self.constraint_refs.is_empty() } - pub fn iter_dir(&self) -> IterationDirection { - match &self.kind { - AccessMethodKind::Scan { iter_dir, .. } => *iter_dir, - AccessMethodKind::Search { iter_dir, .. } => *iter_dir, - } - } -} -#[derive(Debug, Clone)] -/// Represents the kind of access method. -pub enum AccessMethodKind<'a> { - /// A full scan, which can be an index scan or a table scan. - Scan { - index: Option>, - iter_dir: IterationDirection, - }, - /// A search, which can be an index seek or a rowid-based search. - Search { - index: Option>, - iter_dir: IterationDirection, - constraint_refs: &'a [ConstraintRef], - }, + pub fn is_search(&self) -> bool { + !self.constraint_refs.is_empty() + } + + pub fn new_table_scan(input_cardinality: f64, iter_dir: IterationDirection) -> Self { + Self { + cost: estimate_cost_for_scan_or_seek(None, &[], &[], input_cardinality), + iter_dir, + index: None, + constraint_refs: &[], + } + } } /// Return the best [AccessMethod] for a given join order. @@ -63,14 +59,8 @@ pub fn find_best_access_method_for_join_order<'a>( input_cardinality: f64, ) -> Result> { let table_no = join_order.last().unwrap().table_no; - let cost_of_full_table_scan = estimate_cost_for_scan_or_seek(None, &[], &[], input_cardinality); - let mut best_access_method = AccessMethod { - cost: cost_of_full_table_scan, - kind: AccessMethodKind::Scan { - index: None, - iter_dir: IterationDirection::Forwards, - }, - }; + let mut best_access_method = + AccessMethod::new_table_scan(input_cardinality, IterationDirection::Forwards); let rowid_column_idx = rhs_table.columns().iter().position(|c| c.is_rowid_alias); // Estimate cost for each candidate index (including the rowid index) and replace best_access_method if the cost is lower. @@ -151,18 +141,9 @@ pub fn find_best_access_method_for_join_order<'a>( if cost < best_access_method.cost + order_satisfiability_bonus { best_access_method = AccessMethod { cost, - kind: if usable_constraint_refs.is_empty() { - AccessMethodKind::Scan { - index: candidate.index.clone(), - iter_dir, - } - } else { - AccessMethodKind::Search { - index: candidate.index.clone(), - iter_dir, - constraint_refs: &usable_constraint_refs, - } - }, + index: candidate.index.clone(), + iter_dir, + constraint_refs: &usable_constraint_refs, }; } } diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 36d11b967..2c9c676b3 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -481,10 +481,7 @@ mod tests { use crate::{ schema::{BTreeTable, Column, Index, IndexColumn, Table, Type}, translate::{ - optimizer::{ - access_method::AccessMethodKind, - constraints::{constraints_from_where_clause, BinaryExprSide}, - }, + optimizer::constraints::{constraints_from_where_clause, BinaryExprSide}, plan::{ColumnUsedMask, IterationDirection, JoinInfo, Operation, WhereTerm}, planner::TableMask, }, @@ -547,11 +544,9 @@ mod tests { .unwrap() .unwrap(); // Should just be a table scan access method - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); } #[test] @@ -584,18 +579,14 @@ mod tests { assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); assert_eq!(best_plan.table_numbers, vec![0]); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.constraint_refs.len() == 1); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: None, - iter_dir, - constraint_refs, - } - if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[0].constraints[constraint_refs[0].constraint_vec_pos].where_clause_pos == (0, BinaryExprSide::Rhs), - ), - "expected rowid eq access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + table_constraints[0].constraints[access_method.constraint_refs[0].constraint_vec_pos] + .where_clause_pos + == (0, BinaryExprSide::Rhs) ); } @@ -645,18 +636,15 @@ mod tests { assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); assert_eq!(best_plan.table_numbers, vec![0]); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_test_table_1"); + assert!(access_method.constraint_refs.len() == 1); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraint_refs, - } - if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[0].constraints[constraint_refs[0].constraint_vec_pos].lhs_mask.is_empty() && index.name == "sqlite_autoindex_test_table_1" - ), - "expected index search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind + table_constraints[0].constraints[access_method.constraint_refs[0].constraint_vec_pos] + .where_clause_pos + == (0, BinaryExprSide::Rhs) ); } @@ -719,27 +707,19 @@ mod tests { assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); assert_eq!(best_plan.table_numbers, vec![1, 0]); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.as_ref().unwrap().name == "index1"); + assert!(access_method.constraint_refs.len() == 1); assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "expected TableScan access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - ); - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraint_refs, - } - if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[TABLE1].constraints[constraint_refs[0].constraint_vec_pos].where_clause_pos == (0, BinaryExprSide::Rhs) && index.name == "index1", - ), - "expected Search access method, got {:?}", - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind + table_constraints[TABLE1].constraints + [access_method.constraint_refs[0].constraint_vec_pos] + .where_clause_pos + == (0, BinaryExprSide::Rhs) ); } @@ -892,99 +872,32 @@ mod tests { vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] ); - let AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraint_refs, - } = &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind - else { - panic!("expected Search access method with index for first table"); - }; + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_customers_1"); + assert!(access_method.constraint_refs.len() == 1); + let constraint = &table_constraints[TABLE_NO_CUSTOMERS].constraints + [access_method.constraint_refs[0].constraint_vec_pos]; + assert!(constraint.lhs_mask.is_empty()); - assert_eq!( - index.name, "sqlite_autoindex_customers_1", - "wrong index name" - ); - assert_eq!( - *iter_dir, - IterationDirection::Forwards, - "wrong iteration direction" - ); - assert_eq!( - constraint_refs.len(), - 1, - "wrong number of constraint references" - ); - assert!( - table_constraints[TABLE_NO_CUSTOMERS].constraints - [constraint_refs[0].constraint_vec_pos] - .lhs_mask - .is_empty(), - "wrong lhs mask: {:?}", - table_constraints[TABLE_NO_CUSTOMERS].constraints - [constraint_refs[0].constraint_vec_pos] - .lhs_mask - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.as_ref().unwrap().name == "orders_customer_id_idx"); + assert!(access_method.constraint_refs.len() == 1); + let constraint = &table_constraints[TABLE_NO_ORDERS].constraints + [access_method.constraint_refs[0].constraint_vec_pos]; + assert!(constraint.lhs_mask.contains_table(TABLE_NO_CUSTOMERS)); - let AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraint_refs, - } = &access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind - else { - panic!("expected Search access method with index for second table"); - }; - - assert_eq!(index.name, "orders_customer_id_idx", "wrong index name"); - assert_eq!( - *iter_dir, - IterationDirection::Forwards, - "wrong iteration direction" - ); - assert_eq!( - constraint_refs.len(), - 1, - "wrong number of constraint references" - ); - assert!( - table_constraints[TABLE_NO_ORDERS].constraints[constraint_refs[0].constraint_vec_pos] - .lhs_mask - .contains_table(TABLE_NO_CUSTOMERS), - "wrong lhs mask: {:?}", - table_constraints[TABLE_NO_ORDERS].constraints[constraint_refs[0].constraint_vec_pos] - .lhs_mask - ); - - let AccessMethodKind::Search { - index: Some(index), - iter_dir, - constraint_refs, - } = &access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind - else { - panic!("expected Search access method with index for third table"); - }; - - assert_eq!(index.name, "order_items_order_id_idx", "wrong index name"); - assert_eq!( - *iter_dir, - IterationDirection::Forwards, - "wrong iteration direction" - ); - assert_eq!( - constraint_refs.len(), - 1, - "wrong number of constraint references" - ); - assert!( - table_constraints[TABLE_NO_ORDER_ITEMS].constraints - [constraint_refs[0].constraint_vec_pos] - .lhs_mask - .contains_table(TABLE_NO_ORDERS), - "wrong lhs mask: {:?}", - table_constraints[TABLE_NO_ORDER_ITEMS].constraints - [constraint_refs[0].constraint_vec_pos] - .lhs_mask - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[2]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.as_ref().unwrap().name == "order_items_order_id_idx"); + assert!(access_method.constraint_refs.len() == 1); + let constraint = &table_constraints[TABLE_NO_ORDER_ITEMS].constraints + [access_method.constraint_refs[0].constraint_vec_pos]; + assert!(constraint.lhs_mask.contains_table(TABLE_NO_ORDERS)); } struct TestColumn { @@ -1060,23 +973,20 @@ mod tests { // Verify that t2 is chosen first due to its equality filter assert_eq!(best_plan.table_numbers[0], 1); // Verify table scan is used since there are no indexes - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); // Verify that t1 is chosen next due to its inequality filter - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[1]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); // Verify that t3 is chosen last due to no filters - assert!(matches!( - access_methods_arena.borrow()[best_plan.best_access_methods[2]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if iter_dir == IterationDirection::Forwards - )); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[2]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); } #[test] @@ -1166,43 +1076,22 @@ mod tests { ); // Verify access methods - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "First table (fact) should use table scan due to column filter" - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); + assert!(access_method.constraint_refs.is_empty()); for (i, table_number) in best_plan.table_numbers.iter().enumerate().skip(1) { - let AccessMethodKind::Search { - index: None, - iter_dir, - constraint_refs, - } = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind - else { - panic!("expected Search access method for table {}", table_number); - }; - - assert_eq!( - *iter_dir, - IterationDirection::Forwards, - "wrong iteration direction" - ); - assert_eq!( - constraint_refs.len(), - 1, - "wrong number of constraint references" - ); - assert!( - table_constraints[*table_number].constraints[constraint_refs[0].constraint_vec_pos] - .lhs_mask - .contains_table(FACT_TABLE_IDX), - "wrong lhs mask: {:?}", - table_constraints[*table_number].constraints[constraint_refs[0].constraint_vec_pos] - .lhs_mask - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); + assert!(access_method.constraint_refs.len() == 1); + let constraint = &table_constraints[*table_number].constraints + [access_method.constraint_refs[0].constraint_vec_pos]; + assert!(constraint.lhs_mask.contains_table(FACT_TABLE_IDX)); + assert!(constraint.operator == ast::Operator::Equals); } } @@ -1267,32 +1156,23 @@ mod tests { // Verify access methods: // - First table should use Table scan - assert!( - matches!( - &access_methods_arena.borrow()[best_plan.best_access_methods[0]].kind, - AccessMethodKind::Scan { index: None, iter_dir } - if *iter_dir == IterationDirection::Forwards - ), - "First table should use Table scan" - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert!(access_method.is_scan()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); + assert!(access_method.constraint_refs.is_empty()); // all of the rest should use rowid equality for i in 1..NUM_TABLES { - let method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]].kind; - assert!( - matches!( - method, - AccessMethodKind::Search { - index: None, - iter_dir, - constraint_refs, - } - if *iter_dir == IterationDirection::Forwards && constraint_refs.len() == 1 && table_constraints[i].constraints[constraint_refs[0].constraint_vec_pos].lhs_mask.contains_table(i-1) - ), - "Table {} should use Search access method, got {:?}", - i + 1, - method - ); + let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; + assert!(access_method.is_search()); + assert!(access_method.iter_dir == IterationDirection::Forwards); + assert!(access_method.index.is_none()); + assert!(access_method.constraint_refs.len() == 1); + let constraint = &table_constraints[i].constraints + [access_method.constraint_refs[0].constraint_vec_pos]; + assert!(constraint.lhs_mask.contains_table(i - 1)); + assert!(constraint.operator == ast::Operator::Equals); } } diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 1999c9c29..639ce12ac 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -1,6 +1,5 @@ use std::{cell::RefCell, cmp::Ordering, collections::HashMap, sync::Arc}; -use access_method::AccessMethodKind; use constraints::{ constraints_from_where_clause, usable_constraints_for_join_order, BinaryExprSide, Constraint, ConstraintRef, @@ -215,131 +214,132 @@ fn optimize_table_access( // Mutate the Operations in `table_references` to use the selected access methods. for (i, join_order_member) in best_join_order.iter().enumerate() { let table_number = join_order_member.table_no; - let access_method_kind = access_methods_arena.borrow()[best_access_methods[i]] - .kind - .clone(); + let access_method = &access_methods_arena.borrow()[best_access_methods[i]]; if matches!( table_references[table_number].op, Operation::Subquery { .. } ) { // FIXME: Operation::Subquery shouldn't exist. It's not an operation, it's a kind of temporary table. assert!( - matches!(access_method_kind, AccessMethodKind::Scan { index: None, .. }), + access_method.is_scan(), "nothing in the current optimizer should be able to optimize subqueries, but got {:?} for table {}", - access_method_kind, + access_method, table_references[table_number].table.get_name() ); continue; } - table_references[table_number].op = match access_method_kind { - AccessMethodKind::Scan { iter_dir, index } => { - if index.is_some() || i == 0 { - Operation::Scan { iter_dir, index } - } else { - // This branch means we have a full table scan for a non-outermost table. - // Try to construct an ephemeral index since it's going to be better than a scan. - let table_constraints = constraints_per_table - .iter() - .find(|c| c.table_no == table_number); - if let Some(table_constraints) = table_constraints { - let temp_constraint_refs = (0..table_constraints.constraints.len()) - .map(|i| ConstraintRef { - constraint_vec_pos: i, - index_col_pos: table_constraints.constraints[i].table_col_pos, - sort_order: SortOrder::Asc, - }) - .collect::>(); - let usable_constraint_refs = usable_constraints_for_join_order( - &table_constraints.constraints, - &temp_constraint_refs, - &best_join_order[..=i], - ); - if usable_constraint_refs.is_empty() { - Operation::Scan { iter_dir, index } - } else { - let ephemeral_index = ephemeral_index_build( - &table_references[table_number], - table_number, - &table_constraints.constraints, - &usable_constraint_refs, - ); - let ephemeral_index = Arc::new(ephemeral_index); - Operation::Search(Search::Seek { - index: Some(ephemeral_index), - seek_def: build_seek_def_from_constraints( - &table_constraints.constraints, - &usable_constraint_refs, - iter_dir, - where_clause, - )?, - }) - } - } else { - Operation::Scan { iter_dir, index } - } - } + if access_method.is_scan() { + if access_method.index.is_some() || i == 0 { + table_references[table_number].op = Operation::Scan { + iter_dir: access_method.iter_dir, + index: access_method.index.clone(), + }; + continue; } - AccessMethodKind::Search { - index, - constraint_refs, - iter_dir, - } => { - assert!(!constraint_refs.is_empty()); - for cref in constraint_refs.iter() { - let constraint = - &constraints_per_table[table_number].constraints[cref.constraint_vec_pos]; - to_remove_from_where_clause.push(constraint.where_clause_pos.0); - } - if let Some(index) = index { - Operation::Search(Search::Seek { - index: Some(index), - seek_def: build_seek_def_from_constraints( - &constraints_per_table[table_number].constraints, - &constraint_refs, - iter_dir, - where_clause, - )?, - }) - } else { - assert!( - constraint_refs.len() == 1, - "expected exactly one constraint for rowid seek, got {:?}", - constraint_refs - ); - let constraint = &constraints_per_table[table_number].constraints - [constraint_refs[0].constraint_vec_pos]; - match constraint.operator { - ast::Operator::Equals => Operation::Search(Search::RowidEq { - cmp_expr: { - let (idx, side) = constraint.where_clause_pos; - let ast::Expr::Binary(lhs, _, rhs) = - unwrap_parens(&where_clause[idx].expr)? - else { - panic!("Expected a binary expression"); - }; - let where_term = WhereTerm { - expr: match side { - BinaryExprSide::Lhs => lhs.as_ref().clone(), - BinaryExprSide::Rhs => rhs.as_ref().clone(), - }, - from_outer_join: where_clause[idx].from_outer_join.clone(), - }; - where_term + // This branch means we have a full table scan for a non-outermost table. + // Try to construct an ephemeral index since it's going to be better than a scan. + let table_constraints = constraints_per_table + .iter() + .find(|c| c.table_no == table_number); + let Some(table_constraints) = table_constraints else { + table_references[table_number].op = Operation::Scan { + iter_dir: access_method.iter_dir, + index: access_method.index.clone(), + }; + continue; + }; + let temp_constraint_refs = (0..table_constraints.constraints.len()) + .map(|i| ConstraintRef { + constraint_vec_pos: i, + index_col_pos: table_constraints.constraints[i].table_col_pos, + sort_order: SortOrder::Asc, + }) + .collect::>(); + let usable_constraint_refs = usable_constraints_for_join_order( + &table_constraints.constraints, + &temp_constraint_refs, + &best_join_order[..=i], + ); + if usable_constraint_refs.is_empty() { + table_references[table_number].op = Operation::Scan { + iter_dir: access_method.iter_dir, + index: access_method.index.clone(), + }; + continue; + } + let ephemeral_index = ephemeral_index_build( + &table_references[table_number], + table_number, + &table_constraints.constraints, + &usable_constraint_refs, + ); + let ephemeral_index = Arc::new(ephemeral_index); + table_references[table_number].op = Operation::Search(Search::Seek { + index: Some(ephemeral_index), + seek_def: build_seek_def_from_constraints( + &table_constraints.constraints, + &usable_constraint_refs, + access_method.iter_dir, + where_clause, + )?, + }); + } else { + let constraint_refs = access_method.constraint_refs; + assert!(!constraint_refs.is_empty()); + for cref in constraint_refs.iter() { + let constraint = + &constraints_per_table[table_number].constraints[cref.constraint_vec_pos]; + to_remove_from_where_clause.push(constraint.where_clause_pos.0); + } + if let Some(index) = &access_method.index { + table_references[table_number].op = Operation::Search(Search::Seek { + index: Some(index.clone()), + seek_def: build_seek_def_from_constraints( + &constraints_per_table[table_number].constraints, + &constraint_refs, + access_method.iter_dir, + where_clause, + )?, + }); + continue; + } + assert!( + constraint_refs.len() == 1, + "expected exactly one constraint for rowid seek, got {:?}", + constraint_refs + ); + let constraint = &constraints_per_table[table_number].constraints + [constraint_refs[0].constraint_vec_pos]; + table_references[table_number].op = match constraint.operator { + ast::Operator::Equals => Operation::Search(Search::RowidEq { + cmp_expr: { + let (idx, side) = constraint.where_clause_pos; + let ast::Expr::Binary(lhs, _, rhs) = + unwrap_parens(&where_clause[idx].expr)? + else { + panic!("Expected a binary expression"); + }; + let where_term = WhereTerm { + expr: match side { + BinaryExprSide::Lhs => lhs.as_ref().clone(), + BinaryExprSide::Rhs => rhs.as_ref().clone(), }, - }), - _ => Operation::Search(Search::Seek { - index: None, - seek_def: build_seek_def_from_constraints( - &constraints_per_table[table_number].constraints, - &constraint_refs, - iter_dir, - where_clause, - )?, - }), - } - } - } - }; + from_outer_join: where_clause[idx].from_outer_join.clone(), + }; + where_term + }, + }), + _ => Operation::Search(Search::Seek { + index: None, + seek_def: build_seek_def_from_constraints( + &constraints_per_table[table_number].constraints, + &constraint_refs, + access_method.iter_dir, + where_clause, + )?, + }), + }; + } } to_remove_from_where_clause.sort_by_key(|c| *c); for position in to_remove_from_where_clause.iter().rev() { diff --git a/core/translate/optimizer/order.rs b/core/translate/optimizer/order.rs index b6c359915..e79c798e3 100644 --- a/core/translate/optimizer/order.rs +++ b/core/translate/optimizer/order.rs @@ -155,8 +155,8 @@ pub fn plan_satisfies_order_target( // Check if this table has an access method that provides the right ordering. let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; - let iter_dir = access_method.iter_dir(); - let index = access_method.index(); + let iter_dir = access_method.iter_dir; + let index = access_method.index.as_ref(); match index { None => { // No index, so the next required column must be the rowid alias column. From 1d465e6d94d39d4a40b1ae7740cace3669cc5f66 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 12:48:31 +0300 Subject: [PATCH 35/42] Remove unnecessary method --- core/translate/optimizer/access_method.rs | 4 ---- core/translate/optimizer/join.rs | 16 ++++++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/core/translate/optimizer/access_method.rs b/core/translate/optimizer/access_method.rs index b89cc56f8..8888e7755 100644 --- a/core/translate/optimizer/access_method.rs +++ b/core/translate/optimizer/access_method.rs @@ -36,10 +36,6 @@ impl<'a> AccessMethod<'a> { self.constraint_refs.is_empty() } - pub fn is_search(&self) -> bool { - !self.constraint_refs.is_empty() - } - pub fn new_table_scan(input_cardinality: f64, iter_dir: IterationDirection) -> Self { Self { cost: estimate_cost_for_scan_or_seek(None, &[], &[], input_cardinality), diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 2c9c676b3..415d0bb38 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -580,7 +580,7 @@ mod tests { let BestJoinOrderResult { best_plan, .. } = result.unwrap(); assert_eq!(best_plan.table_numbers, vec![0]); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.constraint_refs.len() == 1); assert!( @@ -637,7 +637,7 @@ mod tests { let BestJoinOrderResult { best_plan, .. } = result.unwrap(); assert_eq!(best_plan.table_numbers, vec![0]); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_test_table_1"); assert!(access_method.constraint_refs.len() == 1); @@ -711,7 +711,7 @@ mod tests { assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "index1"); assert!(access_method.constraint_refs.len() == 1); @@ -873,7 +873,7 @@ mod tests { ); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_customers_1"); assert!(access_method.constraint_refs.len() == 1); @@ -882,7 +882,7 @@ mod tests { assert!(constraint.lhs_mask.is_empty()); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "orders_customer_id_idx"); assert!(access_method.constraint_refs.len() == 1); @@ -891,7 +891,7 @@ mod tests { assert!(constraint.lhs_mask.contains_table(TABLE_NO_CUSTOMERS)); let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[2]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "order_items_order_id_idx"); assert!(access_method.constraint_refs.len() == 1); @@ -1084,7 +1084,7 @@ mod tests { for (i, table_number) in best_plan.table_numbers.iter().enumerate().skip(1) { let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); assert!(access_method.constraint_refs.len() == 1); @@ -1165,7 +1165,7 @@ mod tests { // all of the rest should use rowid equality for i in 1..NUM_TABLES { let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; - assert!(access_method.is_search()); + assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); assert!(access_method.constraint_refs.len() == 1); From 5386859b444c5e95760a99af166e1466b4aaf693 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 12:52:42 +0300 Subject: [PATCH 36/42] as_binary-components: simplify --- core/translate/expr.rs | 46 +++++++-------------------------- core/translate/optimizer/mod.rs | 18 ++++++------- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/core/translate/expr.rs b/core/translate/expr.rs index 50e2db073..df7e82724 100644 --- a/core/translate/expr.rs +++ b/core/translate/expr.rs @@ -2580,6 +2580,8 @@ pub fn sanitize_string(input: &str) -> String { input[1..input.len() - 1].replace("''", "'").to_string() } +/// Returns the components of a binary expression +/// e.g. t.x = 5 -> Some((t.x, =, 5)) pub fn as_binary_components( expr: &ast::Expr, ) -> Result> { @@ -2602,41 +2604,13 @@ pub fn as_binary_components( /// Recursively unwrap parentheses from an expression /// e.g. (((t.x > 5))) -> t.x > 5 -pub fn unwrap_parens(expr: T) -> Result -where - T: UnwrapParens, -{ - expr.unwrap_parens() -} - -pub trait UnwrapParens { - fn unwrap_parens(self) -> Result - where - Self: Sized; -} - -impl UnwrapParens for &ast::Expr { - fn unwrap_parens(self) -> Result { - match self { - ast::Expr::Column { .. } => Ok(self), - ast::Expr::Parenthesized(exprs) => match exprs.len() { - 1 => unwrap_parens(exprs.first().unwrap()), - _ => crate::bail_parse_error!("expected single expression in parentheses"), - }, - _ => Ok(self), - } - } -} - -impl UnwrapParens for ast::Expr { - fn unwrap_parens(self) -> Result { - match self { - ast::Expr::Column { .. } => Ok(self), - ast::Expr::Parenthesized(mut exprs) => match exprs.len() { - 1 => unwrap_parens(exprs.pop().unwrap()), - _ => crate::bail_parse_error!("expected single expression in parentheses"), - }, - _ => Ok(self), - } +fn unwrap_parens(expr: &ast::Expr) -> Result<&ast::Expr> { + match expr { + ast::Expr::Column { .. } => Ok(expr), + ast::Expr::Parenthesized(exprs) => match exprs.len() { + 1 => unwrap_parens(exprs.first().unwrap()), + _ => crate::bail_parse_error!("expected single expression in parentheses"), + }, + _ => Ok(expr), } } diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 639ce12ac..3eea86770 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -12,14 +12,13 @@ use order::{compute_order_target, plan_satisfies_order_target, EliminatesSort}; use crate::{ parameters::PARAM_PREFIX, schema::{Index, IndexColumn, Schema}, - translate::plan::TerminationKey, + translate::{expr::as_binary_components, plan::TerminationKey}, types::SeekOp, Result, }; use super::{ emitter::Resolver, - expr::unwrap_parens, plan::{ DeletePlan, GroupBy, IterationDirection, JoinOrderMember, Operation, Plan, Search, SeekDef, SeekKey, SelectPlan, TableReference, UpdatePlan, WhereTerm, @@ -314,15 +313,14 @@ fn optimize_table_access( ast::Operator::Equals => Operation::Search(Search::RowidEq { cmp_expr: { let (idx, side) = constraint.where_clause_pos; - let ast::Expr::Binary(lhs, _, rhs) = - unwrap_parens(&where_clause[idx].expr)? + let Some((lhs, _, rhs)) = as_binary_components(&where_clause[idx].expr)? else { panic!("Expected a binary expression"); }; let where_term = WhereTerm { expr: match side { - BinaryExprSide::Lhs => lhs.as_ref().clone(), - BinaryExprSide::Rhs => rhs.as_ref().clone(), + BinaryExprSide::Lhs => lhs.clone(), + BinaryExprSide::Rhs => rhs.clone(), }, from_outer_join: where_clause[idx].from_outer_join.clone(), }; @@ -822,13 +820,13 @@ pub fn build_seek_def_from_constraints( let constraint = &constraints[cref.constraint_vec_pos]; let (where_idx, side) = constraint.where_clause_pos; let where_term = &where_clause[where_idx]; - let ast::Expr::Binary(lhs, _, rhs) = unwrap_parens(where_term.expr.clone())? else { - crate::bail_parse_error!("expected binary expression"); + let Some((lhs, _, rhs)) = as_binary_components(&where_term.expr)? else { + panic!("Expected a binary expression"); }; let cmp_expr = if side == BinaryExprSide::Lhs { - *lhs + lhs.clone() } else { - *rhs + rhs.clone() }; key.push((cmp_expr, cref.sort_order)); } From 71ab3d57d8a39e2b69137f69e6b1d7c7119841bc Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 14:45:17 +0300 Subject: [PATCH 37/42] constraints.rs: more comments --- core/translate/optimizer/constraints.rs | 60 ++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index b10c6cb8b..bb150c19b 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -13,24 +13,47 @@ use limbo_sqlite3_parser::ast::{self, SortOrder}; use super::cost::ESTIMATED_HARDCODED_ROWS_PER_TABLE; +/// Represents a single condition derived from a `WHERE` clause term +/// that constrains a specific column of a table. +/// +/// Constraints are precomputed for each table involved in a query. They are used +/// during query optimization to estimate the cost of different access paths (e.g., using an index) +/// and to determine the optimal join order. A constraint can only be applied if all tables +/// referenced in its expression (other than the constrained table itself) are already +/// available in the current join context, i.e. on the left side in the join order +/// relative to the table. #[derive(Debug, Clone)] +/// pub struct Constraint { - /// The position of the constraint in the WHERE clause, e.g. in SELECT * FROM t WHERE true AND t.x = 10, the position is (1, BinaryExprSide::Rhs), - /// since the RHS '10' is the constraining expression and it's part of the second term in the WHERE clause. + /// The position of the original `WHERE` clause term this constraint derives from, + /// and which side of the [ast::Expr::Binary] comparison contains the expression + /// that constrains the column. + /// E.g. in SELECT * FROM t WHERE t.x = 10, the constraint is (0, BinaryExprSide::Rhs) + /// because the RHS '10' is the constraining expression. + /// + /// This is tracked so we can: + /// + /// 1. Extract the constraining expression for use in an index seek key, and + /// 2. Remove the relevant binary expression from the WHERE clause, if used as an index seek key. pub where_clause_pos: (usize, BinaryExprSide), - /// The operator of the constraint, e.g. =, >, < + /// The comparison operator (e.g., `=`, `>`, `<`) used in the constraint. pub operator: ast::Operator, - /// The position of the constrained column in the table. + /// The zero-based index of the constrained column within the table's schema. pub table_col_pos: usize, - /// Bitmask of tables that are required to be on the left side of the constrained table, - /// e.g. in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, the lhs_mask contains t2 and t3. + /// A bitmask representing the set of tables that appear on the *constraining* side + /// of the comparison expression. For example, in SELECT * FROM t1,t2,t3 WHERE t1.x = t2.x + t3.x, + /// the lhs_mask contains t2 and t3. Thus, this constraint can only be used if t2 and t3 + /// have already been joined (i.e. are on the left side of the join order relative to t1). pub lhs_mask: TableMask, - /// The selectivity of the constraint, i.e. the fraction of rows that will match the constraint. + /// An estimated selectivity factor (0.0 to 1.0) indicating the fraction of rows + /// expected to satisfy this constraint. Used for cost and cardinality estimation. pub selectivity: f64, } #[derive(Debug, Clone)] /// A reference to a [Constraint] in a [TableConstraints]. +/// +/// This is used to track which constraints may be used as an index seek key. pub struct ConstraintRef { /// The position of the constraint in the [TableConstraints::constraints] vector. pub constraint_vec_pos: usize, @@ -41,6 +64,29 @@ pub struct ConstraintRef { } #[derive(Debug, Clone)] /// A collection of [ConstraintRef]s for a given index, or if index is None, for the table's rowid index. +/// For example, given a table `T (x,y,z)` with an index `T_I (y desc,z)`, take the following query: +/// ```sql +/// SELECT * FROM T WHERE y = 10 AND z = 20; +/// ``` +/// +/// This will produce the following [ConstraintUseCandidate]: +/// +/// ConstraintUseCandidate { +/// index: Some(T_I) +/// refs: [ +/// ConstraintRef { +/// constraint_vec_pos: 0, // y = 10 +/// index_col_pos: 0, // y +/// sort_order: SortOrder::Desc, +/// }, +/// ConstraintRef { +/// constraint_vec_pos: 1, // z = 20 +/// index_col_pos: 1, // z +/// sort_order: SortOrder::Asc, +/// }, +/// ], +/// } +/// pub struct ConstraintUseCandidate { /// The index that may be used to satisfy the constraints. If none, the table's rowid index is used. pub index: Option>, From 625cf005fd1c39b8b277d849ad7b7d7625d7faa0 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 15:36:34 +0300 Subject: [PATCH 38/42] Add some utilities to constraint related structs --- core/translate/main_loop.rs | 8 +---- core/translate/optimizer/constraints.rs | 48 +++++++++++++++++++------ core/translate/optimizer/mod.rs | 42 ++++------------------ core/translate/plan.rs | 2 +- 4 files changed, 46 insertions(+), 54 deletions(-) diff --git a/core/translate/main_loop.rs b/core/translate/main_loop.rs index 5e8c5908a..bf30b8508 100644 --- a/core/translate/main_loop.rs +++ b/core/translate/main_loop.rs @@ -433,13 +433,7 @@ pub fn open_loop( // Rowid equality point lookups are handled with a SeekRowid instruction which does not loop, since it is a single row lookup. if let Search::RowidEq { cmp_expr } = search { let src_reg = program.alloc_register(); - translate_expr( - program, - Some(tables), - &cmp_expr.expr, - src_reg, - &t_ctx.resolver, - )?; + translate_expr(program, Some(tables), cmp_expr, src_reg, &t_ctx.resolver)?; program.emit_insn(Insn::SeekRowid { cursor_id: table_cursor_id .expect("Search::RowidEq requires a table cursor"), diff --git a/core/translate/optimizer/constraints.rs b/core/translate/optimizer/constraints.rs index bb150c19b..a7272ce86 100644 --- a/core/translate/optimizer/constraints.rs +++ b/core/translate/optimizer/constraints.rs @@ -50,6 +50,28 @@ pub struct Constraint { pub selectivity: f64, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BinaryExprSide { + Lhs, + Rhs, +} + +impl Constraint { + /// Get the constraining expression, e.g. '2+3' from 't.x = 2+3' + pub fn get_constraining_expr(&self, where_clause: &[WhereTerm]) -> ast::Expr { + let (idx, side) = self.where_clause_pos; + let where_term = &where_clause[idx]; + let Ok(Some((lhs, _, rhs))) = as_binary_components(&where_term.expr) else { + panic!("Expected a valid binary expression"); + }; + if side == BinaryExprSide::Lhs { + lhs.clone() + } else { + rhs.clone() + } + } +} + #[derive(Debug, Clone)] /// A reference to a [Constraint] in a [TableConstraints]. /// @@ -62,7 +84,20 @@ pub struct ConstraintRef { /// The sort order of the constrained column in the index. Always ascending for rowid indices. pub sort_order: SortOrder, } -#[derive(Debug, Clone)] + +impl ConstraintRef { + /// Convert the constraint to a column usable in a [crate::translate::plan::SeekDef::key]. + pub fn as_seek_key_column( + &self, + constraints: &[Constraint], + where_clause: &[WhereTerm], + ) -> (ast::Expr, SortOrder) { + let constraint = &constraints[self.constraint_vec_pos]; + let constraining_expr = constraint.get_constraining_expr(where_clause); + (constraining_expr, self.sort_order) + } +} + /// A collection of [ConstraintRef]s for a given index, or if index is None, for the table's rowid index. /// For example, given a table `T (x,y,z)` with an index `T_I (y desc,z)`, take the following query: /// ```sql @@ -87,6 +122,7 @@ pub struct ConstraintRef { /// ], /// } /// +#[derive(Debug)] pub struct ConstraintUseCandidate { /// The index that may be used to satisfy the constraints. If none, the table's rowid index is used. pub index: Option>, @@ -104,16 +140,6 @@ pub struct TableConstraints { pub candidates: Vec, } -/// Helper enum for [Constraint] to indicate which side of a binary comparison expression is being compared to the index column. -/// For example, if the where clause is "WHERE x = 10" and there's an index on x, -/// the [Constraint] for the where clause term "x = 10" will have a [BinaryExprSide::Rhs] -/// because the right hand side expression "10" is being compared to the index column "x". -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BinaryExprSide { - Lhs, - Rhs, -} - /// In lieu of statistics, we estimate that an equality filter will reduce the output set to 1% of its size. const SELECTIVITY_EQ: f64 = 0.01; /// In lieu of statistics, we estimate that a range filter will reduce the output set to 40% of its size. diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 3eea86770..0353d7b84 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -1,8 +1,7 @@ use std::{cell::RefCell, cmp::Ordering, collections::HashMap, sync::Arc}; use constraints::{ - constraints_from_where_clause, usable_constraints_for_join_order, BinaryExprSide, Constraint, - ConstraintRef, + constraints_from_where_clause, usable_constraints_for_join_order, Constraint, ConstraintRef, }; use cost::Cost; use join::{compute_best_join_order, BestJoinOrderResult}; @@ -12,7 +11,7 @@ use order::{compute_order_target, plan_satisfies_order_target, EliminatesSort}; use crate::{ parameters::PARAM_PREFIX, schema::{Index, IndexColumn, Schema}, - translate::{expr::as_binary_components, plan::TerminationKey}, + translate::plan::TerminationKey, types::SeekOp, Result, }; @@ -311,21 +310,7 @@ fn optimize_table_access( [constraint_refs[0].constraint_vec_pos]; table_references[table_number].op = match constraint.operator { ast::Operator::Equals => Operation::Search(Search::RowidEq { - cmp_expr: { - let (idx, side) = constraint.where_clause_pos; - let Some((lhs, _, rhs)) = as_binary_components(&where_clause[idx].expr)? - else { - panic!("Expected a binary expression"); - }; - let where_term = WhereTerm { - expr: match side { - BinaryExprSide::Lhs => lhs.clone(), - BinaryExprSide::Rhs => rhs.clone(), - }, - from_outer_join: where_clause[idx].from_outer_join.clone(), - }; - where_term - }, + cmp_expr: constraint.get_constraining_expr(where_clause), }), _ => Operation::Search(Search::Seek { index: None, @@ -813,23 +798,10 @@ pub fn build_seek_def_from_constraints( "cannot build seek def from empty list of constraint refs" ); // Extract the key values and operators - let mut key = Vec::with_capacity(constraint_refs.len()); - - for cref in constraint_refs { - // Extract the other expression from the binary WhereTerm (i.e. the one being compared to the index column) - let constraint = &constraints[cref.constraint_vec_pos]; - let (where_idx, side) = constraint.where_clause_pos; - let where_term = &where_clause[where_idx]; - let Some((lhs, _, rhs)) = as_binary_components(&where_term.expr)? else { - panic!("Expected a binary expression"); - }; - let cmp_expr = if side == BinaryExprSide::Lhs { - lhs.clone() - } else { - rhs.clone() - }; - key.push((cmp_expr, cref.sort_order)); - } + let key = constraint_refs + .iter() + .map(|cref| cref.as_seek_key_column(constraints, where_clause)) + .collect(); // We know all but potentially the last term is an equality, so we can use the operator of the last term // to form the SeekOp diff --git a/core/translate/plan.rs b/core/translate/plan.rs index 50e325832..2a047b3a1 100644 --- a/core/translate/plan.rs +++ b/core/translate/plan.rs @@ -782,7 +782,7 @@ pub struct TerminationKey { #[derive(Clone, Debug)] pub enum Search { /// A rowid equality point lookup. This is a special case that uses the SeekRowid bytecode instruction and does not loop. - RowidEq { cmp_expr: WhereTerm }, + RowidEq { cmp_expr: ast::Expr }, /// A search on a table btree (via `rowid`) or a secondary index search. Uses bytecode instructions like SeekGE, SeekGT etc. Seek { index: Option>, From d2fa91e9845db92e539c6fbdc219d447c8607056 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 22:38:51 +0300 Subject: [PATCH 39/42] avoid growing vec --- core/translate/optimizer/join.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 415d0bb38..297fe21e4 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -56,13 +56,15 @@ pub fn join_lhs_and_rhs<'a>( let rhs_table_number = join_order.last().unwrap().table_no; let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { - let mut numbers = l.table_numbers.clone(); + let mut numbers = Vec::with_capacity(l.table_numbers.len() + 1); + numbers.extend(l.table_numbers.iter().cloned()); numbers.push(rhs_table_number); numbers }); access_methods_arena.borrow_mut().push(best_access_method); - let mut best_access_methods = lhs.map_or(vec![], |l| l.best_access_methods.clone()); + let mut best_access_methods = Vec::with_capacity(new_numbers.len()); + best_access_methods.extend(lhs.map_or(vec![], |l| l.best_access_methods.clone())); best_access_methods.push(access_methods_arena.borrow().len() - 1); let lhs_mask = lhs.map_or(TableMask::new(), |l| { From 5e5788bdfe8904fb551e926a4e34c821b39aaff1 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 23:18:53 +0300 Subject: [PATCH 40/42] Reduce allocations --- core/translate/optimizer/join.rs | 101 ++++++++++++++++-------------- core/translate/optimizer/mod.rs | 6 +- core/translate/optimizer/order.rs | 4 +- 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 297fe21e4..5f9caa3a5 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -19,16 +19,26 @@ use super::{ /// Represents an n-ary join, anywhere from 1 table to N tables. #[derive(Debug, Clone)] pub struct JoinN { - /// Identifiers of the tables in the best_plan - pub table_numbers: Vec, - /// The best access methods for the best_plans - pub best_access_methods: Vec, + /// Tuple: (table_number, access_method_index) + pub data: Vec<(usize, usize)>, /// The estimated number of rows returned by joining these n tables together. pub output_cardinality: usize, /// Estimated execution cost of this N-ary join. pub cost: Cost, } +impl JoinN { + pub fn table_numbers(&self) -> impl Iterator + use<'_> { + self.data.iter().map(|(table_number, _)| *table_number) + } + + pub fn best_access_methods(&self) -> impl Iterator + use<'_> { + self.data + .iter() + .map(|(_, access_method_index)| *access_method_index) + } +} + /// Join n-1 tables with the n'th table. pub fn join_lhs_and_rhs<'a>( lhs: Option<&JoinN>, @@ -54,21 +64,16 @@ pub fn join_lhs_and_rhs<'a>( let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); let cost = lhs_cost + best_access_method.cost; - let rhs_table_number = join_order.last().unwrap().table_no; - let new_numbers = lhs.map_or(vec![rhs_table_number], |l| { - let mut numbers = Vec::with_capacity(l.table_numbers.len() + 1); - numbers.extend(l.table_numbers.iter().cloned()); - numbers.push(rhs_table_number); - numbers - }); - access_methods_arena.borrow_mut().push(best_access_method); - let mut best_access_methods = Vec::with_capacity(new_numbers.len()); - best_access_methods.extend(lhs.map_or(vec![], |l| l.best_access_methods.clone())); - best_access_methods.push(access_methods_arena.borrow().len() - 1); + + let mut best_access_methods = Vec::with_capacity(join_order.len()); + best_access_methods.extend(lhs.map_or(vec![], |l| l.data.clone())); + + let rhs_table_number = join_order.last().unwrap().table_no; + best_access_methods.push((rhs_table_number, access_methods_arena.borrow().len() - 1)); let lhs_mask = lhs.map_or(TableMask::new(), |l| { - TableMask::from_table_number_iter(l.table_numbers.iter().cloned()) + TableMask::from_table_number_iter(l.table_numbers()) }); // Output cardinality is reduced by the product of the selectivities of the constraints that can be used with this join order. let output_cardinality_multiplier = rhs_constraints @@ -87,8 +92,7 @@ pub fn join_lhs_and_rhs<'a>( .ceil() as usize; Ok(JoinN { - table_numbers: new_numbers, - best_access_methods, + data: best_access_methods, output_cardinality, cost, }) @@ -281,10 +285,10 @@ pub fn compute_best_join_order<'a>( }; // Build a JoinOrder out of the table bitmask we are now considering. - for table_no in lhs.table_numbers.iter() { + for table_no in lhs.table_numbers() { join_order.push(JoinOrderMember { - table_no: *table_no, - is_outer: table_references[*table_no] + table_no, + is_outer: table_references[table_no] .join_info .as_ref() .map_or(false, |j| j.outer), @@ -546,7 +550,7 @@ mod tests { .unwrap() .unwrap(); // Should just be a table scan access method - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); } @@ -580,8 +584,8 @@ mod tests { .unwrap(); assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![0]); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert_eq!(best_plan.table_numbers().collect::>(), vec![0]); + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.constraint_refs.len() == 1); @@ -637,8 +641,8 @@ mod tests { .unwrap(); assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![0]); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert_eq!(best_plan.table_numbers().collect::>(), vec![0]); + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_test_table_1"); @@ -708,11 +712,11 @@ mod tests { .unwrap(); assert!(result.is_some()); let BestJoinOrderResult { best_plan, .. } = result.unwrap(); - assert_eq!(best_plan.table_numbers, vec![1, 0]); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + assert_eq!(best_plan.table_numbers().collect::>(), vec![1, 0]); + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[1].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "index1"); @@ -870,11 +874,11 @@ mod tests { // Customers (due to =42 filter) -> Orders (due to index on customer_id) -> Order_items (due to index on order_id) assert_eq!( - best_plan.table_numbers, + best_plan.table_numbers().collect::>(), vec![TABLE_NO_CUSTOMERS, TABLE_NO_ORDERS, TABLE_NO_ORDER_ITEMS] ); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "sqlite_autoindex_customers_1"); @@ -883,7 +887,7 @@ mod tests { [access_method.constraint_refs[0].constraint_vec_pos]; assert!(constraint.lhs_mask.is_empty()); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[1].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "orders_customer_id_idx"); @@ -892,7 +896,7 @@ mod tests { [access_method.constraint_refs[0].constraint_vec_pos]; assert!(constraint.lhs_mask.contains_table(TABLE_NO_CUSTOMERS)); - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[2]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[2].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.as_ref().unwrap().name == "order_items_order_id_idx"); @@ -973,19 +977,19 @@ mod tests { .unwrap(); // Verify that t2 is chosen first due to its equality filter - assert_eq!(best_plan.table_numbers[0], 1); + assert_eq!(best_plan.table_numbers().nth(0).unwrap(), 1); // Verify table scan is used since there are no indexes - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); // Verify that t1 is chosen next due to its inequality filter - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[1]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[1].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); // Verify that t3 is chosen last due to no filters - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[2]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[2].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); @@ -1072,20 +1076,22 @@ mod tests { // Expected optimal order: fact table as outer, with rowid seeks in any order on each dimension table // Verify fact table is selected as the outer table as all the other tables can use SeekRowid assert_eq!( - best_plan.table_numbers[0], FACT_TABLE_IDX, + best_plan.table_numbers().nth(0).unwrap(), + FACT_TABLE_IDX, "First table should be fact (table {}) due to available index, got table {} instead", - FACT_TABLE_IDX, best_plan.table_numbers[0] + FACT_TABLE_IDX, + best_plan.table_numbers().nth(0).unwrap() ); // Verify access methods - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); assert!(access_method.constraint_refs.is_empty()); - for (i, table_number) in best_plan.table_numbers.iter().enumerate().skip(1) { - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; + for (table_number, access_method_index) in best_plan.data.iter().skip(1) { + let access_method = &access_methods_arena.borrow()[*access_method_index]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); @@ -1150,15 +1156,18 @@ mod tests { // Verify the join order is exactly t1 -> t2 -> t3 -> t4 -> t5 for i in 0..NUM_TABLES { assert_eq!( - best_plan.table_numbers[i], i, + best_plan.table_numbers().nth(i).unwrap(), + i, "Expected table {} at position {}, got table {} instead", - i, i, best_plan.table_numbers[i] + i, + i, + best_plan.table_numbers().nth(i).unwrap() ); } // Verify access methods: // - First table should use Table scan - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[0]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[0].1]; assert!(access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); @@ -1166,7 +1175,7 @@ mod tests { // all of the rest should use rowid equality for i in 1..NUM_TABLES { - let access_method = &access_methods_arena.borrow()[best_plan.best_access_methods[i]]; + let access_method = &access_methods_arena.borrow()[best_plan.data[i].1]; assert!(!access_method.is_scan()); assert!(access_method.iter_dir == IterationDirection::Forwards); assert!(access_method.index.is_none()); diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index 0353d7b84..88ec01604 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -194,8 +194,10 @@ fn optimize_table_access( } } - let (best_access_methods, best_table_numbers) = - (best_plan.best_access_methods, best_plan.table_numbers); + let (best_access_methods, best_table_numbers) = ( + best_plan.best_access_methods().collect::>(), + best_plan.table_numbers().collect::>(), + ); let best_join_order: Vec = best_table_numbers .into_iter() diff --git a/core/translate/optimizer/order.rs b/core/translate/optimizer/order.rs index e79c798e3..b6ff1ade3 100644 --- a/core/translate/optimizer/order.rs +++ b/core/translate/optimizer/order.rs @@ -145,7 +145,7 @@ pub fn plan_satisfies_order_target( ) -> bool { let mut target_col_idx = 0; let num_cols_in_order_target = order_target.0.len(); - for (i, table_no) in plan.table_numbers.iter().enumerate() { + for (table_no, access_method_index) in plan.data.iter() { let target_col = &order_target.0[target_col_idx]; let table_ref = &table_references[*table_no]; let correct_table = target_col.table_no == *table_no; @@ -154,7 +154,7 @@ pub fn plan_satisfies_order_target( } // Check if this table has an access method that provides the right ordering. - let access_method = &access_methods_arena.borrow()[plan.best_access_methods[i]]; + let access_method = &access_methods_arena.borrow()[*access_method_index]; let iter_dir = access_method.iter_dir; let index = access_method.index.as_ref(); match index { From eb983c88c6d5d61b53a72fc982afe3dcec0fa1e5 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 23:31:04 +0300 Subject: [PATCH 41/42] reserve capacity for memo hashmap entries --- core/translate/optimizer/join.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index 5f9caa3a5..b7e3c0255 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -177,7 +177,8 @@ pub fn compute_best_join_order<'a>( // if we find that 'b JOIN a' is better than 'a JOIN b', then we don't need to even try // to do 'a JOIN b JOIN c', because we know 'b JOIN a JOIN c' is going to be better. // This is due to the commutativity and associativity of inner joins. - let mut best_plan_memo: HashMap = HashMap::new(); + let mut best_plan_memo: HashMap = + HashMap::with_capacity(2usize.pow(num_tables as u32 - 1)); // Dynamic programming base case: calculate the best way to access each single table, as if // there were no other tables. From 176d9bd3c73568a677e370f288f4bdc4c4a4918e Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 12 May 2025 23:44:28 +0300 Subject: [PATCH 42/42] Prune bad plans earlier to avoid allocating useless JoinN structs --- core/translate/optimizer/join.rs | 43 +++++++++++++++++++------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/core/translate/optimizer/join.rs b/core/translate/optimizer/join.rs index b7e3c0255..aceb2b889 100644 --- a/core/translate/optimizer/join.rs +++ b/core/translate/optimizer/join.rs @@ -40,6 +40,7 @@ impl JoinN { } /// Join n-1 tables with the n'th table. +/// Returns None if the plan is worse than the provided cost upper bound. pub fn join_lhs_and_rhs<'a>( lhs: Option<&JoinN>, rhs_table_reference: &TableReference, @@ -47,7 +48,8 @@ pub fn join_lhs_and_rhs<'a>( join_order: &[JoinOrderMember], maybe_order_target: Option<&OrderTarget>, access_methods_arena: &'a RefCell>>, -) -> Result { + cost_upper_bound: Cost, +) -> Result> { // The input cardinality for this join is the output cardinality of the previous join. // For example, in a 2-way join, if the left table has 1000 rows, and the right table will return 2 rows for each of the left table's rows, // then the output cardinality of the join will be 2000. @@ -64,6 +66,10 @@ pub fn join_lhs_and_rhs<'a>( let lhs_cost = lhs.map_or(Cost(0.0), |l| l.cost); let cost = lhs_cost + best_access_method.cost; + if cost > cost_upper_bound { + return Ok(None); + } + access_methods_arena.borrow_mut().push(best_access_method); let mut best_access_methods = Vec::with_capacity(join_order.len()); @@ -91,11 +97,11 @@ pub fn join_lhs_and_rhs<'a>( * output_cardinality_multiplier) .ceil() as usize; - Ok(JoinN { + Ok(Some(JoinN { data: best_access_methods, output_cardinality, cost, - }) + })) } /// The result of [compute_best_join_order]. @@ -164,13 +170,7 @@ pub fn compute_best_join_order<'a>( // Keep track of the current best cost so we can short-circuit planning for subplans // that already exceed the cost of the current best plan. let cost_upper_bound = best_plan.cost; - let cost_upper_bound_ordered = { - if best_plan_is_also_ordered { - cost_upper_bound - } else { - Cost(f64::MAX) - } - }; + let cost_upper_bound_ordered = best_plan.cost; // Keep track of the best plan for a given subset of tables. // Consider this example: we have tables a,b,c,d to join. @@ -198,8 +198,11 @@ pub fn compute_best_join_order<'a>( &join_order, maybe_order_target, access_methods_arena, + cost_upper_bound_ordered, )?; - best_plan_memo.insert(mask, rel); + if let Some(rel) = rel { + best_plan_memo.insert(mask, rel); + } } join_order.clear(); @@ -312,15 +315,13 @@ pub fn compute_best_join_order<'a>( &join_order, maybe_order_target, access_methods_arena, + cost_upper_bound_ordered, )?; join_order.clear(); - // Since cost_upper_bound_ordered is always >= to cost_upper_bound, - // if the cost we calculated for this plan is worse than cost_upper_bound_ordered, - // this join subset is already worse than our best plan for the ENTIRE query, so skip. - if rel.cost >= cost_upper_bound_ordered { + let Some(rel) = rel else { continue; - } + }; let satisfies_order_target = if let Some(ref order_target) = maybe_order_target { plan_satisfies_order_target( @@ -414,7 +415,9 @@ pub fn compute_naive_left_deep_plan<'a>( &join_order[..1], maybe_order_target, access_methods_arena, - )?; + Cost(f64::MAX), + )? + .expect("call to join_lhs_and_rhs in compute_naive_left_deep_plan always returns Some(JoinN)"); // Add remaining tables one at a time from left to right for i in 1..n { @@ -425,7 +428,11 @@ pub fn compute_naive_left_deep_plan<'a>( &join_order[..=i], maybe_order_target, access_methods_arena, - )?; + Cost(f64::MAX), + )? + .expect( + "call to join_lhs_and_rhs in compute_naive_left_deep_plan always returns Some(JoinN)", + ); } Ok(best_plan)