use std::sync::Arc;
use super::access_path::{AccessPath, BTreeAccess, IndexRef, RangeBound, select_access_path};
use crate::catalog::{Index, IndexDefinition};
use crate::exec::planner::util::try_literal_to_value;
use crate::expr::operator::{MatchesOperator, NearestNeighbor, PrefixOperator};
use crate::expr::order::Ordering;
use crate::expr::with::With;
use crate::expr::{BinaryOperator, Cond, Expr, Idiom};
use crate::idx::planner::ScanDirection;
use crate::val::{Number, Value};
pub struct IndexAnalyzer<'a> {
pub indexes: Arc<[IndexDefinition]>,
pub with_hints: Option<&'a With>,
}
impl<'a> IndexAnalyzer<'a> {
pub fn new(indexes: Arc<[IndexDefinition]>, with_hints: Option<&'a With>) -> Self {
Self {
indexes,
with_hints,
}
}
pub fn analyze(&self, cond: Option<&Cond>, order: Option<&Ordering>) -> Vec<IndexCandidate> {
let mut candidates = Vec::new();
if self.indexes.is_empty() {
return candidates;
}
if let Some(cond) = cond {
let mut conditions = Vec::new();
self.collect_conditions(&cond.0, &mut conditions);
self.analyze_compound_conditions(&conditions, &mut candidates);
self.analyze_condition(&cond.0, &mut candidates);
}
if let Some(ordering) = order {
self.analyze_order(ordering, &mut candidates);
}
if let Some(With::Index(names)) = self.with_hints {
candidates.retain(|c| names.iter().any(|n| n.as_str() == c.index_ref.name.as_str()));
}
self.merge_range_candidates(&mut candidates);
self.deduplicate_candidates(&mut candidates);
candidates
}
pub fn try_or_union(
&self,
cond: Option<&Cond>,
direction: ScanDirection,
) -> Option<AccessPath> {
let cond = cond?;
if matches!(self.with_hints, Some(With::NoIndex)) {
return None;
}
let mut branches = Vec::new();
Self::flatten_or(&cond.0, &mut branches);
if branches.len() < 2 {
return None;
}
let mut branch_paths = Vec::with_capacity(branches.len());
for branch_expr in branches {
let branch_cond = Cond(branch_expr.clone());
let candidates = self.analyze(Some(&branch_cond), None);
if candidates.is_empty() {
return None;
}
let path = select_access_path(candidates, self.with_hints, direction);
match path {
AccessPath::TableScan => {
return None;
}
AccessPath::EmptyScan => {
continue;
}
_ => branch_paths.push(path),
}
}
if branch_paths.is_empty() {
return Some(AccessPath::EmptyScan);
}
if branch_paths.len() == 1 {
return branch_paths.into_iter().next();
}
Some(AccessPath::Union {
paths: branch_paths,
dedupe: true,
})
}
const MAX_IN_EXPANSION_SIZE: usize = 32;
pub fn try_in_expansion(
&self,
cond: Option<&Cond>,
direction: ScanDirection,
) -> Option<AccessPath> {
let cond = cond?;
if matches!(self.with_hints, Some(With::NoIndex)) {
return None;
}
let mut in_exprs = Vec::new();
Self::collect_in_expressions(&cond.0, &mut in_exprs);
for (idiom, values) in &in_exprs {
if values.len() < 2 || values.len() > Self::MAX_IN_EXPANSION_SIZE {
continue; }
let mut best: Option<(usize, usize)> = None;
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if !matches!(ix_def.index, crate::catalog::Index::Idx | crate::catalog::Index::Uniq)
{
continue;
}
if let Some(With::Index(names)) = self.with_hints
&& !names.iter().any(|n| n.as_str() == ix_def.name.as_str())
{
continue;
}
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches(idiom, first_col)
{
let ncols = ix_def.cols.len();
if best.is_none_or(|(_, best_ncols)| ncols < best_ncols) {
best = Some((idx, ncols));
}
}
}
if let Some((idx, ncols)) = best {
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
let paths: Vec<AccessPath> = if ncols == 1 {
values
.iter()
.map(|v| AccessPath::BTreeScan {
index_ref: index_ref.clone(),
access: BTreeAccess::Equality(v.clone()),
direction,
})
.collect()
} else {
values
.iter()
.map(|v| AccessPath::BTreeScan {
index_ref: index_ref.clone(),
access: BTreeAccess::Compound {
prefix: vec![v.clone()],
range: None,
},
direction,
})
.collect()
};
return Some(AccessPath::Union {
paths,
dedupe: false,
});
}
}
None
}
pub fn try_containment_expansion(
&self,
cond: Option<&Cond>,
direction: ScanDirection,
) -> Option<AccessPath> {
let cond = cond?;
if matches!(self.with_hints, Some(With::NoIndex)) {
return None;
}
let mut exprs = Vec::new();
Self::collect_containment_expressions(&cond.0, &mut exprs);
for (idiom, values) in &exprs {
if values.is_empty() || values.len() > Self::MAX_IN_EXPANSION_SIZE {
continue;
}
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if !matches!(ix_def.index, crate::catalog::Index::Idx | crate::catalog::Index::Uniq)
{
continue;
}
if let Some(With::Index(names)) = self.with_hints
&& !names.iter().any(|n| n.as_str() == ix_def.name.as_str())
{
continue;
}
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches_containment(idiom, first_col)
{
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
let is_composite = ix_def.cols.len() > 1;
let paths: Vec<AccessPath> = values
.iter()
.map(|v| {
let access = if is_composite {
BTreeAccess::Compound {
prefix: vec![v.clone()],
range: None,
}
} else {
BTreeAccess::Equality(v.clone())
};
AccessPath::BTreeScan {
index_ref: index_ref.clone(),
access,
direction,
}
})
.collect();
return Some(AccessPath::Union {
paths,
dedupe: true,
});
}
}
}
None
}
fn collect_containment_expressions(expr: &Expr, results: &mut Vec<(Idiom, Vec<Value>)>) {
match expr {
Expr::Binary {
left,
op: BinaryOperator::And,
right,
} => {
Self::collect_containment_expressions(left, results);
Self::collect_containment_expressions(right, results);
}
Expr::Binary {
left,
op: BinaryOperator::ContainAll | BinaryOperator::ContainAny,
right,
} => {
if let (Expr::Idiom(idiom), Expr::Literal(lit)) = (left.as_ref(), right.as_ref())
&& let Some(Value::Array(arr)) = try_literal_to_value(lit)
{
results.push((idiom.clone(), arr.0));
}
}
Expr::Binary {
left,
op: BinaryOperator::AllInside | BinaryOperator::AnyInside,
right,
} => {
if let (Expr::Literal(lit), Expr::Idiom(idiom)) = (left.as_ref(), right.as_ref())
&& let Some(Value::Array(arr)) = try_literal_to_value(lit)
{
results.push((idiom.clone(), arr.0));
}
}
Expr::Prefix {
op,
expr: inner,
} if !matches!(op, PrefixOperator::Not) => {
Self::collect_containment_expressions(inner, results);
}
_ => {}
}
}
fn collect_in_expressions(expr: &Expr, results: &mut Vec<(Idiom, Vec<Value>)>) {
match expr {
Expr::Binary {
left,
op: BinaryOperator::And,
right,
} => {
Self::collect_in_expressions(left, results);
Self::collect_in_expressions(right, results);
}
Expr::Binary {
left,
op: BinaryOperator::Inside,
right,
} => {
if let (Expr::Idiom(idiom), Expr::Literal(lit)) = (left.as_ref(), right.as_ref())
&& let Some(Value::Array(arr)) = try_literal_to_value(lit)
{
results.push((idiom.clone(), arr.0));
}
}
Expr::Prefix {
op,
expr: inner,
} if !matches!(op, PrefixOperator::Not) => {
Self::collect_in_expressions(inner, results);
}
_ => {}
}
}
fn flatten_or<'b>(expr: &'b Expr, branches: &mut Vec<&'b Expr>) {
match expr {
Expr::Binary {
left,
op: BinaryOperator::Or,
right,
} => {
Self::flatten_or(left, branches);
Self::flatten_or(right, branches);
}
_ => {
branches.push(expr);
}
}
}
fn collect_conditions(&self, expr: &Expr, conditions: &mut Vec<SimpleCondition>) {
match expr {
Expr::Binary {
left,
op,
right,
} => {
match op {
BinaryOperator::And => {
self.collect_conditions(left, conditions);
self.collect_conditions(right, conditions);
}
BinaryOperator::Or => {
}
_ => {
if let Some(cond) = self.extract_simple_condition(left, op, right) {
conditions.push(cond);
}
}
}
}
Expr::Prefix {
op,
expr: inner,
} if !matches!(op, PrefixOperator::Not) => {
self.collect_conditions(inner, conditions);
}
_ => {}
}
}
fn extract_simple_condition(
&self,
left: &Expr,
op: &BinaryOperator,
right: &Expr,
) -> Option<SimpleCondition> {
let (idiom, value, position) = match (left, right) {
(Expr::Idiom(idiom), Expr::Literal(lit)) => {
if let Some(value) = try_literal_to_value(lit) {
(idiom.clone(), value, IdiomPosition::Left)
} else {
return None;
}
}
(Expr::Literal(lit), Expr::Idiom(idiom)) => {
if let Some(value) = try_literal_to_value(lit) {
(idiom.clone(), value, IdiomPosition::Right)
} else {
return None;
}
}
_ => return None,
};
let (op, value) = if matches!(op, BinaryOperator::Inside) && position == IdiomPosition::Left
{
if let Value::Array(arr) = &value
&& arr.len() == 1
{
(BinaryOperator::Equal, arr[0].clone())
} else {
(op.clone(), value)
}
} else {
(op.clone(), value)
};
Some(SimpleCondition {
idiom,
op,
value,
position,
})
}
fn analyze_compound_conditions(
&self,
conditions: &[SimpleCondition],
candidates: &mut Vec<IndexCandidate>,
) {
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if !matches!(ix_def.index, Index::Idx | Index::Uniq) {
continue;
}
if ix_def.cols.len() < 2 {
continue;
}
let mut prefix_values = Vec::new();
let mut range_condition: Option<(BinaryOperator, Value)> = None;
for col in &ix_def.cols {
let matching_cond = conditions.iter().find(|c| idiom_matches(&c.idiom, col));
match matching_cond {
Some(cond) => {
let is_equality =
matches!(cond.op, BinaryOperator::Equal | BinaryOperator::ExactEqual);
if is_equality {
prefix_values.push(cond.value.clone());
} else {
if let Some(op) = normalize_range_op(&cond.op, cond.position) {
range_condition = Some((op, cond.value.clone()));
} else if matches!(cond.op, BinaryOperator::NotEqual)
&& matches!(cond.value, Value::None)
{
range_condition =
Some((BinaryOperator::MoreThan, cond.value.clone()));
}
break;
}
}
None => {
break;
}
}
}
if prefix_values.len() >= 2 || (!prefix_values.is_empty() && range_condition.is_some())
{
let access = BTreeAccess::Compound {
prefix: prefix_values,
range: range_condition,
};
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
candidates.push(IndexCandidate::new(index_ref, access));
}
}
}
fn merge_range_candidates(&self, candidates: &mut Vec<IndexCandidate>) {
candidates.sort_by_key(|c| c.index_ref.idx);
let mut i = 0;
while i < candidates.len() {
let mut j = i + 1;
while j < candidates.len() && candidates[j].index_ref.idx == candidates[i].index_ref.idx
{
match Self::try_merge_ranges(&candidates[i].access, &candidates[j].access) {
Some(Ok(merged_access)) => {
let covers_order = candidates[i].covers_order || candidates[j].covers_order;
candidates[i].access = merged_access;
candidates[i].covers_order = covers_order;
candidates.remove(j);
}
Some(Err(())) => {
candidates[i].empty = true;
candidates.remove(j);
}
None => {
j += 1;
}
}
}
i += 1;
}
}
#[allow(clippy::result_unit_err)]
fn try_merge_ranges(a: &BTreeAccess, b: &BTreeAccess) -> Option<Result<BTreeAccess, ()>> {
let (
BTreeAccess::Range {
from: from_a,
to: to_a,
},
BTreeAccess::Range {
from: from_b,
to: to_b,
},
) = (a, b)
else {
return None;
};
if from_a.is_none() && to_a.is_none() && from_b.is_none() && to_b.is_none() {
return None;
}
let merged_from = match (from_a, from_b) {
(Some(fa), Some(fb)) => Some(Self::tighter_from(fa, fb)?),
(Some(f), None) | (None, Some(f)) => Some(f.clone()),
(None, None) => None,
};
let merged_to = match (to_a, to_b) {
(Some(ta), Some(tb)) => Some(Self::tighter_to(ta, tb)?),
(Some(t), None) | (None, Some(t)) => Some(t.clone()),
(None, None) => None,
};
if let (Some(f), Some(t)) = (merged_from.as_ref(), merged_to.as_ref())
&& bounds_are_unsatisfiable(f, t)
{
return Some(Err(()));
}
Some(Ok(BTreeAccess::Range {
from: merged_from,
to: merged_to,
}))
}
fn tighter_from(a: &RangeBound, b: &RangeBound) -> Option<RangeBound> {
let cmp = a.value.partial_cmp(&b.value)?;
Some(match cmp {
std::cmp::Ordering::Less => b.clone(),
std::cmp::Ordering::Greater => a.clone(),
std::cmp::Ordering::Equal => {
if !a.inclusive {
a.clone()
} else {
b.clone()
}
}
})
}
fn tighter_to(a: &RangeBound, b: &RangeBound) -> Option<RangeBound> {
let cmp = a.value.partial_cmp(&b.value)?;
Some(match cmp {
std::cmp::Ordering::Less => a.clone(),
std::cmp::Ordering::Greater => b.clone(),
std::cmp::Ordering::Equal => {
if !a.inclusive {
a.clone()
} else {
b.clone()
}
}
})
}
fn deduplicate_candidates(&self, candidates: &mut Vec<IndexCandidate>) {
candidates.sort_by(|a, b| match a.index_ref.idx.cmp(&b.index_ref.idx) {
std::cmp::Ordering::Equal => b.score().cmp(&a.score()),
other => other,
});
candidates.dedup_by(|a, b| a.index_ref.idx == b.index_ref.idx);
}
fn analyze_condition(&self, expr: &Expr, candidates: &mut Vec<IndexCandidate>) {
match expr {
Expr::Binary {
left,
op,
right,
} => {
match op {
BinaryOperator::And => {
self.analyze_condition(left, candidates);
self.analyze_condition(right, candidates);
}
BinaryOperator::Or => {
}
BinaryOperator::Matches(mo) => {
self.try_match_fulltext(left, mo, right, candidates);
}
BinaryOperator::NearestNeighbor(nn) => {
self.try_match_knn(left, right, nn, candidates);
}
BinaryOperator::Contain | BinaryOperator::Inside => {
self.try_match_containment(left, op, right, candidates);
self.try_match_comparison(left, op, right, candidates);
}
_ => {
self.try_match_comparison(left, op, right, candidates);
}
}
}
Expr::Prefix {
op,
expr: inner,
} if !matches!(op, PrefixOperator::Not) => {
self.analyze_condition(inner, candidates);
}
_ => {}
}
}
fn try_match_comparison(
&self,
left: &Expr,
op: &BinaryOperator,
right: &Expr,
candidates: &mut Vec<IndexCandidate>,
) {
let (idiom, value, position) = match (left, right) {
(Expr::Idiom(idiom), Expr::Literal(lit)) => {
if let Some(value) = try_literal_to_value(lit) {
(idiom, value, IdiomPosition::Left)
} else {
return;
}
}
(Expr::Literal(lit), Expr::Idiom(idiom)) => {
if let Some(value) = try_literal_to_value(lit) {
(idiom, value, IdiomPosition::Right)
} else {
return;
}
}
_ => return,
};
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches(idiom, first_col)
&& let Some(access) =
self.match_operator_to_access(op, &value, position, &ix_def.index)
{
let access = if ix_def.cols.len() > 1 {
match access {
BTreeAccess::Equality(v) => BTreeAccess::Compound {
prefix: vec![v],
range: None,
},
BTreeAccess::Range {
from,
to,
} => {
BTreeAccess::Range {
from,
to,
}
}
other => other,
}
} else {
access
};
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
candidates.push(IndexCandidate::new(index_ref, access));
}
}
}
fn match_operator_to_access(
&self,
op: &BinaryOperator,
value: &Value,
position: IdiomPosition,
index_type: &Index,
) -> Option<BTreeAccess> {
if !matches!(index_type, Index::Idx | Index::Uniq) {
return None;
}
match (op, position) {
(BinaryOperator::Equal | BinaryOperator::ExactEqual, _) => {
Some(BTreeAccess::Equality(value.clone()))
}
(BinaryOperator::LessThan, IdiomPosition::Left) => Some(BTreeAccess::Range {
from: None,
to: Some(RangeBound::exclusive(value.clone())),
}),
(BinaryOperator::LessThanEqual, IdiomPosition::Left) => Some(BTreeAccess::Range {
from: None,
to: Some(RangeBound::inclusive(value.clone())),
}),
(BinaryOperator::MoreThan, IdiomPosition::Left) => Some(BTreeAccess::Range {
from: Some(RangeBound::exclusive(value.clone())),
to: None,
}),
(BinaryOperator::MoreThanEqual, IdiomPosition::Left) => Some(BTreeAccess::Range {
from: Some(RangeBound::inclusive(value.clone())),
to: None,
}),
(BinaryOperator::LessThan, IdiomPosition::Right) => Some(BTreeAccess::Range {
from: Some(RangeBound::exclusive(value.clone())),
to: None,
}),
(BinaryOperator::LessThanEqual, IdiomPosition::Right) => Some(BTreeAccess::Range {
from: Some(RangeBound::inclusive(value.clone())),
to: None,
}),
(BinaryOperator::MoreThan, IdiomPosition::Right) => Some(BTreeAccess::Range {
from: None,
to: Some(RangeBound::exclusive(value.clone())),
}),
(BinaryOperator::MoreThanEqual, IdiomPosition::Right) => Some(BTreeAccess::Range {
from: None,
to: Some(RangeBound::inclusive(value.clone())),
}),
(BinaryOperator::Inside, IdiomPosition::Left) => {
if let Value::Array(arr) = value
&& arr.len() == 1
{
Some(BTreeAccess::Equality(arr[0].clone()))
} else {
None
}
}
(BinaryOperator::NotEqual, _) if matches!(value, Value::None) => {
Some(BTreeAccess::Range {
from: Some(RangeBound::exclusive(value.clone())),
to: None,
})
}
_ => None,
}
}
fn try_match_containment(
&self,
left: &Expr,
op: &BinaryOperator,
right: &Expr,
candidates: &mut Vec<IndexCandidate>,
) {
let (idiom, value) = match op {
BinaryOperator::Contain => match (left, right) {
(Expr::Idiom(idiom), Expr::Literal(lit)) => {
if let Some(v) = try_literal_to_value(lit) {
(idiom, v)
} else {
return;
}
}
_ => return,
},
BinaryOperator::Inside => match (left, right) {
(Expr::Literal(lit), Expr::Idiom(idiom)) => {
if let Some(v) = try_literal_to_value(lit) {
(idiom, v)
} else {
return;
}
}
_ => return,
},
_ => return,
};
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if !matches!(ix_def.index, Index::Idx | Index::Uniq) {
continue;
}
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches_containment(idiom, first_col)
{
let access = if ix_def.cols.len() > 1 {
BTreeAccess::Compound {
prefix: vec![value.clone()],
range: None,
}
} else {
BTreeAccess::Equality(value.clone())
};
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
candidates.push(IndexCandidate::new(index_ref, access));
}
}
}
fn try_match_fulltext(
&self,
left: &Expr,
operator: &MatchesOperator,
right: &Expr,
candidates: &mut Vec<IndexCandidate>,
) {
let (idiom, query) = match (left, right) {
(Expr::Idiom(idiom), Expr::Literal(lit)) => {
if let Some(Value::String(s)) = try_literal_to_value(lit) {
(idiom, s)
} else {
return;
}
}
_ => return,
};
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
if !matches!(ix_def.index, Index::FullText(_)) {
continue;
}
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches(idiom, first_col)
{
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
candidates.push(IndexCandidate::new(
index_ref,
BTreeAccess::FullText {
query: query.as_str().to_owned(),
operator: operator.clone(),
},
));
}
}
}
fn try_match_knn(
&self,
left: &Expr,
right: &Expr,
nn: &NearestNeighbor,
candidates: &mut Vec<IndexCandidate>,
) {
let (k, user_ef, required_distance) = match nn {
NearestNeighbor::Approximate(k, ef) => (*k, Some(*ef), None),
NearestNeighbor::K(k, d) => (*k, None, Some(d)),
_ => return,
};
let idiom = match left {
Expr::Idiom(idiom) => idiom,
_ => return,
};
let vector = match right {
Expr::Literal(lit) => {
if let Some(Value::Array(arr)) = try_literal_to_value(lit) {
let nums: Vec<Number> = arr
.iter()
.filter_map(|v| match v {
Value::Number(n) => Some(*n),
_ => None,
})
.collect();
if nums.len() != arr.len() {
return;
}
nums
} else {
return;
}
}
_ => return,
};
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove {
continue;
}
let ef = match &ix_def.index {
Index::Hnsw(hnsw) => {
if let Some(d) = required_distance
&& *d != hnsw.distance
{
continue;
}
user_ef.unwrap_or_else(|| k.max(hnsw.ef_construction as u32))
}
Index::DiskAnn(diskann) => {
if let Some(d) = required_distance
&& *d != diskann.distance
{
continue;
}
user_ef.unwrap_or_else(|| k.max(diskann.l_build as u32))
}
_ => continue,
};
if let Some(first_col) = ix_def.cols.first()
&& idiom_matches(idiom, first_col)
{
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
candidates.push(IndexCandidate::new(
index_ref,
BTreeAccess::Knn {
vector: vector.clone(),
k,
ef,
},
));
}
}
}
fn analyze_order(&self, ordering: &Ordering, candidates: &mut Vec<IndexCandidate>) {
use crate::exec::planner::util::index_covers_ordering;
for candidate in candidates.iter_mut() {
if covers_ordering_either_direction(
&candidate.index_ref,
&candidate.access,
ordering,
index_covers_ordering,
) {
candidate.covers_order = true;
}
}
for (idx, ix_def) in self.indexes.iter().enumerate() {
if ix_def.prepare_remove || !ix_def.index.supports_order() {
continue;
}
if candidates.iter().any(|c| c.index_ref.idx == idx) {
continue;
}
let index_ref = IndexRef::new(Arc::clone(&self.indexes), idx);
let full_range = BTreeAccess::Range {
from: None,
to: None,
};
if covers_ordering_either_direction(
&index_ref,
&full_range,
ordering,
index_covers_ordering,
) {
let mut candidate = IndexCandidate::new(index_ref, full_range);
candidate.covers_order = true;
candidates.push(candidate);
}
}
}
}
fn covers_ordering_either_direction<F>(
index_ref: &IndexRef,
access: &BTreeAccess,
ordering: &Ordering,
covers: F,
) -> bool
where
F: Fn(&IndexRef, &BTreeAccess, ScanDirection, &Ordering) -> bool,
{
covers(index_ref, access, ScanDirection::Forward, ordering)
|| covers(index_ref, access, ScanDirection::Backward, ordering)
}
#[derive(Debug, Clone)]
pub struct IndexCandidate {
pub index_ref: IndexRef,
pub access: BTreeAccess,
pub covers_order: bool,
pub empty: bool,
}
impl IndexCandidate {
pub fn new(index_ref: IndexRef, access: BTreeAccess) -> Self {
Self {
index_ref,
access,
covers_order: false,
empty: false,
}
}
pub fn score(&self) -> u32 {
const MAX_COMPOUND_PREFIX_BONUS_COLS: u32 = 6;
if self.empty {
return u32::MAX;
}
let mut score = 0u32;
match &self.access {
BTreeAccess::Equality(_) => {
score += if self.index_ref.is_unique() {
1000
} else {
500
};
}
BTreeAccess::Compound {
prefix,
range,
} => {
let len = (prefix.len() as u32).min(MAX_COMPOUND_PREFIX_BONUS_COLS);
score += 400 + len * 50;
if range.is_some() {
score += 25;
}
}
BTreeAccess::Range {
from,
to,
} => {
score += match (from.is_some(), to.is_some()) {
(true, true) => 300,
(true, false) | (false, true) => 200,
(false, false) => 50,
};
}
BTreeAccess::FullText {
..
} => {
score += 800;
}
BTreeAccess::Knn {
..
} => {
score += 800;
}
}
if self.covers_order {
score += 100;
}
score
}
pub fn to_access_path(&self, direction: ScanDirection) -> AccessPath {
if self.empty {
return AccessPath::EmptyScan;
}
match &self.access {
BTreeAccess::FullText {
query,
operator,
} => AccessPath::FullTextSearch {
index_ref: self.index_ref.clone(),
query: query.clone(),
operator: operator.clone(),
},
BTreeAccess::Knn {
vector,
k,
ef,
} => AccessPath::KnnSearch {
index_ref: self.index_ref.clone(),
vector: vector.clone(),
k: *k,
ef: *ef,
},
_ => AccessPath::BTreeScan {
index_ref: self.index_ref.clone(),
access: self.access.clone(),
direction,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IdiomPosition {
Left,
Right,
}
#[derive(Debug, Clone)]
struct SimpleCondition {
idiom: Idiom,
op: BinaryOperator,
value: Value,
position: IdiomPosition,
}
fn idiom_matches(expr_idiom: &Idiom, index_col: &Idiom) -> bool {
use crate::expr::Part;
if expr_idiom != index_col {
return false;
}
if index_col.0.iter().any(|p| matches!(p, Part::All)) {
return false;
}
true
}
pub(crate) fn idiom_matches_containment(expr_idiom: &Idiom, index_col: &Idiom) -> bool {
use crate::expr::Part;
if !index_col.0.iter().any(|p| matches!(p, Part::All)) {
return false;
}
let col_without_all: Vec<&Part> =
index_col.0.iter().filter(|p| !matches!(p, Part::All)).collect();
let expr_without_all: Vec<&Part> =
expr_idiom.0.iter().filter(|p| !matches!(p, Part::All)).collect();
col_without_all == expr_without_all
}
fn bounds_are_unsatisfiable(from: &RangeBound, to: &RangeBound) -> bool {
use std::cmp::Ordering;
match from.value.partial_cmp(&to.value) {
Some(Ordering::Greater) => true,
Some(Ordering::Equal) => !(from.inclusive && to.inclusive),
Some(Ordering::Less) => false,
None => false,
}
}
fn normalize_range_op(op: &BinaryOperator, position: IdiomPosition) -> Option<BinaryOperator> {
match position {
IdiomPosition::Left => match op {
BinaryOperator::MoreThan
| BinaryOperator::MoreThanEqual
| BinaryOperator::LessThan
| BinaryOperator::LessThanEqual => Some(op.clone()),
_ => None,
},
IdiomPosition::Right => match op {
BinaryOperator::LessThan => Some(BinaryOperator::MoreThan),
BinaryOperator::LessThanEqual => Some(BinaryOperator::MoreThanEqual),
BinaryOperator::MoreThan => Some(BinaryOperator::LessThan),
BinaryOperator::MoreThanEqual => Some(BinaryOperator::LessThanEqual),
_ => None,
},
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use std::sync::Arc;
use surrealdb_strand::Strand;
use super::*;
use crate::catalog::{Index, IndexDefinition, IndexId};
use crate::expr::order::Ordering;
use crate::expr::with::With;
use crate::expr::{Cond, Expr, Idiom};
use crate::val::TableName;
fn idx_def(id: u32, name: &str, cols: &[&str], kind: Index) -> IndexDefinition {
IndexDefinition {
index_id: IndexId(id),
name: Strand::from(name),
table_name: TableName::from("t"),
cols: cols.iter().map(|c| Idiom::from_str(c).expect("valid idiom")).collect(),
index: kind,
comment: None,
prepare_remove: false,
}
}
fn idx_basic(id: u32, name: &str, cols: &[&str]) -> IndexDefinition {
idx_def(id, name, cols, Index::Idx)
}
fn idx_uniq(id: u32, name: &str, cols: &[&str]) -> IndexDefinition {
idx_def(id, name, cols, Index::Uniq)
}
fn analyzer<'a>(defs: Vec<IndexDefinition>, with: Option<&'a With>) -> IndexAnalyzer<'a> {
IndexAnalyzer::new(Arc::<[_]>::from(defs.into_boxed_slice()), with)
}
fn parse_select_parts(snippet: &str) -> (Option<Cond>, Option<Ordering>, Option<With>) {
let src = format!("SELECT * FROM t {snippet}");
let ast = crate::syn::parse(&src).expect("parse");
let mut exprs = ast.expressions;
assert_eq!(exprs.len(), 1, "expected one statement from {src:?}");
let top: crate::expr::TopLevelExpr = exprs.remove(0).into();
match top {
crate::expr::TopLevelExpr::Expr(Expr::Select(s)) => (s.cond, s.order, s.with),
other => panic!("expected SELECT, got {other:?}"),
}
}
fn parse_cond(snippet: &str) -> Cond {
let (cond, _, _) = parse_select_parts(&format!("WHERE {snippet}"));
cond.expect("WHERE produced a Cond")
}
fn parse_cond_order(where_snippet: &str, order_snippet: &str) -> (Cond, Ordering) {
let (cond, order, _) =
parse_select_parts(&format!("WHERE {where_snippet} ORDER BY {order_snippet}"));
(cond.expect("WHERE"), order.expect("ORDER BY"))
}
fn find_for<'a>(cands: &'a [IndexCandidate], index_name: &str) -> Option<&'a IndexCandidate> {
cands.iter().find(|c| c.index_ref.name.as_str() == index_name)
}
fn assert_no_candidate(cands: &[IndexCandidate], index_name: &str) {
assert!(
find_for(cands, index_name).is_none(),
"expected no candidate for index {index_name:?}, got {:?}",
cands.iter().map(|c| c.index_ref.name.as_str()).collect::<Vec<_>>()
);
}
mod equality {
use super::*;
#[test]
fn idx_equality_left_idiom() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a = 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate");
assert!(matches!(c.access, BTreeAccess::Equality(_)));
}
#[test]
fn idx_equality_right_idiom() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("5 = a");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate");
assert!(matches!(c.access, BTreeAccess::Equality(_)));
}
#[test]
fn uniq_equality_outranks_non_unique() {
let a = analyzer(
vec![idx_basic(1, "ix_a_basic", &["a"]), idx_uniq(2, "ix_a_uniq", &["a"])],
None,
);
let cond = parse_cond("a = 5");
let cands = a.analyze(Some(&cond), None);
let path = super::super::super::access_path::select_access_path(
cands,
None,
crate::idx::planner::ScanDirection::Forward,
);
match path {
AccessPath::BTreeScan {
index_ref,
..
} => {
assert_eq!(index_ref.name.as_str(), "ix_a_uniq", "uniq must win");
}
other => panic!("expected BTreeScan, got {other:?}"),
}
}
#[test]
fn first_col_of_compound_becomes_prefix() {
let a = analyzer(vec![idx_basic(1, "ix_ab", &["a", "b"])], None);
let cond = parse_cond("a = 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_ab").expect("ix_ab candidate");
match &c.access {
BTreeAccess::Compound {
prefix,
range,
} => {
assert_eq!(prefix.len(), 1);
assert!(range.is_none());
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn idiom_mismatch_yields_no_candidate() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("b = 5");
let cands = a.analyze(Some(&cond), None);
assert!(cands.is_empty(), "no index matches column b");
}
}
mod range {
use super::*;
#[test]
fn half_bounded_gt() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
assert!(from.is_some());
assert!(to.is_none());
assert!(!from.as_ref().unwrap().inclusive, "MoreThan is exclusive");
}
other => panic!("expected Range, got {other:?}"),
}
}
#[test]
fn half_bounded_gte() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a >= 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
..
} => {
assert!(from.as_ref().unwrap().inclusive, "MoreThanEqual is inclusive");
}
other => panic!("expected Range, got {other:?}"),
}
}
#[test]
fn bounded_after_merge() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 5 AND a < 10");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
assert!(from.is_some());
assert!(to.is_some());
}
other => panic!("expected merged Range, got {other:?}"),
}
}
#[test]
fn value_lt_idiom_normalises() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("5 < a");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
assert!(from.is_some());
assert!(to.is_none());
}
other => panic!("expected Range from-bound, got {other:?}"),
}
}
}
mod compound {
use super::*;
#[test]
fn two_equalities_form_prefix() {
let a = analyzer(vec![idx_basic(1, "ix_abc", &["a", "b", "c"])], None);
let cond = parse_cond("a = 1 AND b = 2");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_abc").expect("ix_abc");
match &c.access {
BTreeAccess::Compound {
prefix,
range,
} => {
assert_eq!(prefix.len(), 2, "two equalities → prefix length 2");
assert!(range.is_none());
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn equality_then_range() {
let a = analyzer(vec![idx_basic(1, "ix_abc", &["a", "b", "c"])], None);
let cond = parse_cond("a = 1 AND b > 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_abc").expect("ix_abc");
match &c.access {
BTreeAccess::Compound {
prefix,
range,
} => {
assert_eq!(prefix.len(), 1);
assert!(range.is_some(), "range on b captured");
}
other => panic!("expected Compound with range, got {other:?}"),
}
}
#[test]
fn trailing_equality_after_range_is_dropped() {
let a = analyzer(vec![idx_basic(1, "ix_abc", &["a", "b", "c"])], None);
let cond = parse_cond("a = 1 AND b > 5 AND c = 2");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_abc").expect("ix_abc");
match &c.access {
BTreeAccess::Compound {
prefix,
range,
} => {
assert_eq!(prefix.len(), 1);
assert!(range.is_some());
}
other => panic!("expected Compound, got {other:?}"),
}
}
#[test]
fn middle_column_only_no_candidate() {
let a = analyzer(vec![idx_basic(1, "ix_ab", &["a", "b"])], None);
let cond = parse_cond("b = 2");
let cands = a.analyze(Some(&cond), None);
assert_no_candidate(&cands, "ix_ab");
}
}
mod order_by {
use super::*;
#[test]
fn equality_then_order_by_id_covers() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let (cond, order) = parse_cond_order("a = 1", "id");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_a").expect("ix_a");
assert!(c.covers_order, "WHERE a=1 ORDER BY id on INDEX(a) covers order");
}
#[test]
fn equality_then_unrelated_order_not_covered() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let (cond, order) = parse_cond_order("a = 1", "b");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_a").expect("ix_a");
assert!(!c.covers_order, "ORDER BY non-id non-prefix must not be covered");
}
#[test]
fn order_by_extending_past_index_not_covered() {
let a = analyzer(vec![idx_basic(1, "ix_ab", &["a", "b"])], None);
let (cond, order) = parse_cond_order("a = 1", "a, b, c");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_ab").expect("ix_ab");
assert!(
!c.covers_order,
"ORDER BY extends past the index — sort elimination is unsafe"
);
}
#[test]
fn order_by_single_col_extending_past_index() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let (cond, order) = parse_cond_order("a > 5", "a, b");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_a").expect("ix_a");
assert!(!c.covers_order, "ORDER BY a, b on INDEX(a) must NOT claim sort elimination");
}
#[test]
fn mixed_asc_desc_not_covered() {
let a = analyzer(vec![idx_basic(1, "ix_ab", &["a", "b"])], None);
let (cond, order) = parse_cond_order("a = 1", "a ASC, b DESC");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_ab").expect("ix_ab");
assert!(c.covers_order, "ORDER BY pinned ASC + col DESC IS coverable by backward scan");
}
#[test]
fn order_by_desc_on_indexed_col_covered() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let (cond, order) = parse_cond_order("a > 5", "a DESC");
let cands = a.analyze(Some(&cond), Some(&order));
let c = find_for(&cands, "ix_a").expect("ix_a");
assert!(c.covers_order, "ORDER BY DESC coverable via backward scan");
}
#[test]
fn order_by_only_no_where() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let (_, order, _) = parse_select_parts("ORDER BY a");
let order = order.expect("ORDER BY");
let cands = a.analyze(None, Some(&order));
let c = find_for(&cands, "ix_a").expect("synth ix_a candidate");
assert!(c.covers_order);
assert!(matches!(
c.access,
BTreeAccess::Range {
from: None,
to: None
}
));
}
}
mod hints {
use super::*;
#[test]
fn with_index_filters_to_named() {
let defs = vec![idx_basic(1, "ix_a", &["a"]), idx_basic(2, "ix_b", &["a"])];
let (cond, _, with) = parse_select_parts("WITH INDEX ix_b WHERE a = 1");
let cond = cond.expect("WHERE");
let with = with.expect("WITH");
let a = analyzer(defs, Some(&with));
let cands = a.analyze(Some(&cond), None);
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].index_ref.name.as_str(), "ix_b");
}
#[test]
fn with_noindex_returns_empty_candidates() {
let (cond, _, with) = parse_select_parts("WITH NOINDEX WHERE a = 1");
let cond = cond.expect("WHERE");
let with = with.expect("WITH");
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], Some(&with));
let cands = a.analyze(Some(&cond), None);
let path = super::super::super::access_path::select_access_path(
cands,
Some(&with),
crate::idx::planner::ScanDirection::Forward,
);
assert!(matches!(path, AccessPath::TableScan), "NOINDEX → TableScan");
}
}
mod negation {
use super::*;
#[test]
fn not_equal_does_not_index() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a != 5");
let cands = a.analyze(Some(&cond), None);
assert_no_candidate(&cands, "ix_a");
}
#[test]
fn negated_predicate_not_indexed() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("!(a = 5)");
let cands = a.analyze(Some(&cond), None);
assert_no_candidate(&cands, "ix_a");
}
#[test]
fn is_not_null_is_not_indexed() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a != NULL");
let cands = a.analyze(Some(&cond), None);
assert_no_candidate(&cands, "ix_a");
}
#[test]
fn is_not_none_uses_index_range() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a != NONE");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate for != NONE");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
let from = from.as_ref().expect("from bound");
assert!(matches!(from.value, crate::val::Value::None));
assert!(!from.inclusive);
assert!(to.is_none());
}
other => panic!("expected exclusive Range from NONE, got {other:?}"),
}
}
}
mod in_expansion {
use super::*;
#[test]
fn small_in_expands_to_union() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a IN [1, 2, 3]");
let path = a
.try_in_expansion(Some(&cond), crate::idx::planner::ScanDirection::Forward)
.expect("IN expansion");
match path {
AccessPath::Union {
paths,
dedupe,
} => {
assert_eq!(paths.len(), 3);
assert!(!dedupe, "scalar IN-expansion branches are record-disjoint");
}
other => panic!("expected Union, got {other:?}"),
}
}
#[test]
fn oversized_in_skips_expansion() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let lit = (1..=33).map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
let cond = parse_cond(&format!("a IN [{lit}]"));
let path = a.try_in_expansion(Some(&cond), crate::idx::planner::ScanDirection::Forward);
assert!(path.is_none(), "33-element IN should not expand");
}
#[test]
fn in_threshold_boundary_at_32() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let lit = (1..=32).map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
let cond = parse_cond(&format!("a IN [{lit}]"));
let path = a
.try_in_expansion(Some(&cond), crate::idx::planner::ScanDirection::Forward)
.expect("32-element IN expands");
match path {
AccessPath::Union {
paths,
dedupe,
} => {
assert_eq!(paths.len(), 32);
assert!(!dedupe);
}
other => panic!("expected Union, got {other:?}"),
}
}
}
mod or_union {
use super::*;
#[test]
fn or_both_indexed() {
let defs = vec![idx_basic(1, "ix_a", &["a"]), idx_basic(2, "ix_b", &["b"])];
let a = analyzer(defs, None);
let cond = parse_cond("a = 1 OR b = 2");
let path = a
.try_or_union(Some(&cond), crate::idx::planner::ScanDirection::Forward)
.expect("union");
match path {
AccessPath::Union {
paths,
dedupe,
} => {
assert_eq!(paths.len(), 2);
assert!(dedupe, "OR branches may both hold on the same row");
}
other => panic!("expected Union, got {other:?}"),
}
}
#[test]
fn or_one_branch_unindexed_no_union() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a = 1 OR b = 2");
let path = a.try_or_union(Some(&cond), crate::idx::planner::ScanDirection::Forward);
assert!(path.is_none(), "unindexed branch defeats union");
}
#[test]
fn or_drops_empty_branch_from_union() {
let a =
analyzer(vec![idx_basic(1, "ix_a", &["a"]), idx_basic(2, "ix_b", &["b"])], None);
let cond = parse_cond("(a > 10 AND a < 5) OR b = 1");
let path = a
.try_or_union(Some(&cond), crate::idx::planner::ScanDirection::Forward)
.expect("union or degenerate path");
match path {
AccessPath::BTreeScan {
index_ref,
access,
..
} => {
assert_eq!(index_ref.name.as_str(), "ix_b");
assert!(matches!(access, BTreeAccess::Equality(_)));
}
AccessPath::Union {
..
} => {
panic!("empty branch should have been dropped, leaving a single path")
}
other => panic!("expected BTreeScan(ix_b), got {other:?}"),
}
}
#[test]
fn or_all_branches_empty_yields_empty_scan() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("(a > 10 AND a < 5) OR (a > 100 AND a < 50)");
let path = a
.try_or_union(Some(&cond), crate::idx::planner::ScanDirection::Forward)
.expect("union path");
assert!(matches!(path, AccessPath::EmptyScan));
}
}
mod range_merge {
use super::*;
use crate::idx::planner::ScanDirection;
fn select_path(cands: Vec<IndexCandidate>) -> AccessPath {
super::super::super::access_path::select_access_path(
cands,
None,
ScanDirection::Forward,
)
}
#[test]
fn contradictory_range_yields_empty_scan() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 10 AND a < 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate");
assert!(c.empty, "contradiction must mark candidate empty");
assert!(matches!(select_path(cands), AccessPath::EmptyScan));
}
#[test]
fn singleton_range_inclusive_both_sides() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a >= 5 AND a <= 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate");
assert!(!c.empty, "inclusive both sides on same value is non-empty");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
assert!(from.as_ref().unwrap().inclusive);
assert!(to.as_ref().unwrap().inclusive);
}
other => panic!("expected Range, got {other:?}"),
}
}
#[test]
fn equal_values_with_one_exclusive_is_empty() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 5 AND a <= 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a candidate");
assert!(c.empty, "x > 5 AND x <= 5 is empty");
}
#[test]
fn same_side_from_bounds_keep_tighter() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 5 AND a > 10");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
let from = from.as_ref().expect("from bound present");
assert!(matches!(&from.value,
crate::val::Value::Number(n) if n.to_int() == 10
));
assert!(!from.inclusive, "exclusive `>` survives");
assert!(to.is_none());
}
other => panic!("expected single tightened Range, got {other:?}"),
}
}
#[test]
fn same_side_to_bounds_keep_tighter() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a < 100 AND a < 50");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
to,
} => {
assert!(from.is_none());
let to = to.as_ref().expect("to bound present");
assert!(matches!(&to.value,
crate::val::Value::Number(n) if n.to_int() == 50
));
assert!(!to.inclusive);
}
other => panic!("expected single tightened Range, got {other:?}"),
}
}
#[test]
fn same_side_mixed_inclusive_picks_exclusive() {
let a = analyzer(vec![idx_basic(1, "ix_a", &["a"])], None);
let cond = parse_cond("a > 5 AND a >= 5");
let cands = a.analyze(Some(&cond), None);
let c = find_for(&cands, "ix_a").expect("ix_a");
match &c.access {
BTreeAccess::Range {
from,
..
} => {
let from = from.as_ref().expect("from");
assert!(!from.inclusive, "exclusive wins on ties");
}
other => panic!("expected Range, got {other:?}"),
}
}
}
mod scoring {
use super::*;
#[test]
fn unique_equality_beats_non_unique() {
let uniq_cand = IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(vec![idx_uniq(1, "u", &["a"])].into_boxed_slice()),
0,
),
access: BTreeAccess::Equality(crate::val::Value::Number(crate::val::Number::Int(
1,
))),
covers_order: false,
empty: false,
};
let nonunique_cand = IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(vec![idx_basic(1, "i", &["a"])].into_boxed_slice()),
0,
),
access: BTreeAccess::Equality(crate::val::Value::Number(crate::val::Number::Int(
1,
))),
covers_order: false,
empty: false,
};
assert!(
uniq_cand.score() > nonunique_cand.score(),
"unique equality must outscore non-unique"
);
}
#[test]
fn bounded_range_beats_half_bounded() {
let make = |from_some, to_some| IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(vec![idx_basic(1, "i", &["a"])].into_boxed_slice()),
0,
),
access: BTreeAccess::Range {
from: if from_some {
Some(RangeBound::inclusive(crate::val::Value::Number(
crate::val::Number::Int(0),
)))
} else {
None
},
to: if to_some {
Some(RangeBound::inclusive(crate::val::Value::Number(
crate::val::Number::Int(10),
)))
} else {
None
},
},
covers_order: false,
empty: false,
};
assert!(make(true, true).score() > make(true, false).score());
assert!(make(true, false).score() > make(false, false).score());
}
#[test]
fn compound_prefix_bonus_is_capped() {
let big_prefix = (0..12u32)
.map(|i| crate::val::Value::Number(crate::val::Number::Int(i as i64)))
.collect::<Vec<_>>();
let wide = IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(
vec![idx_basic(
1,
"i_wide",
&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
)]
.into_boxed_slice(),
),
0,
),
access: BTreeAccess::Compound {
prefix: big_prefix,
range: None,
},
covers_order: false,
empty: false,
};
let unique_eq = IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(vec![idx_uniq(2, "u_a", &["a"])].into_boxed_slice()),
0,
),
access: BTreeAccess::Equality(crate::val::Value::Number(crate::val::Number::Int(
1,
))),
covers_order: false,
empty: false,
};
assert!(
unique_eq.score() > wide.score(),
"unique equality must outscore wide compound prefix"
);
}
#[test]
fn covers_order_provides_positive_bonus() {
let make = |covers| IndexCandidate {
index_ref: IndexRef::new(
Arc::<[_]>::from(vec![idx_basic(1, "i", &["a"])].into_boxed_slice()),
0,
),
access: BTreeAccess::Range {
from: Some(RangeBound::inclusive(crate::val::Value::Number(
crate::val::Number::Int(0),
))),
to: None,
},
covers_order: covers,
empty: false,
};
assert!(make(true).score() > make(false).score());
}
}
}