blob: 1d59dab8fd6dddabd409b8ba96ec06f546a1c007 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Interval and selectivity in [`AnalysisContext`]
use std::fmt::Debug;
use std::sync::Arc;
use crate::expressions::Column;
use crate::intervals::cp_solver::{ExprIntervalGraph, PropagationResult};
use crate::utils::collect_columns;
use crate::PhysicalExpr;
use arrow::datatypes::Schema;
use datafusion_common::stats::Precision;
use datafusion_common::{
internal_datafusion_err, internal_err, ColumnStatistics, Result, ScalarValue,
};
use datafusion_expr::interval_arithmetic::{cardinality_ratio, Interval};
/// The shared context used during the analysis of an expression. Includes
/// the boundaries for all known columns.
#[derive(Clone, Debug, PartialEq)]
pub struct AnalysisContext {
// A list of known column boundaries, ordered by the index
// of the column in the current schema.
pub boundaries: Vec<ExprBoundaries>,
/// The estimated percentage of rows that this expression would select, if
/// it were to be used as a boolean predicate on a filter. The value will be
/// between 0.0 (selects nothing) and 1.0 (selects everything).
pub selectivity: Option<f64>,
}
impl AnalysisContext {
pub fn new(boundaries: Vec<ExprBoundaries>) -> Self {
Self {
boundaries,
selectivity: None,
}
}
pub fn with_selectivity(mut self, selectivity: f64) -> Self {
self.selectivity = Some(selectivity);
self
}
/// Create a new analysis context from column statistics.
pub fn try_from_statistics(
input_schema: &Schema,
statistics: &[ColumnStatistics],
) -> Result<Self> {
statistics
.iter()
.enumerate()
.map(|(idx, stats)| ExprBoundaries::try_from_column(input_schema, stats, idx))
.collect::<Result<Vec<_>>>()
.map(Self::new)
}
}
/// Represents the boundaries (e.g. min and max values) of a particular column
///
/// This is used range analysis of expressions, to determine if the expression
/// limits the value of particular columns (e.g. analyzing an expression such as
/// `time < 50` would result in a boundary interval for `time` having a max
/// value of `50`).
#[derive(Clone, Debug, PartialEq)]
pub struct ExprBoundaries {
pub column: Column,
/// Minimum and maximum values this expression can have. A `None` value
/// indicates that evaluating the given column results in an empty set.
/// For example, if the column `a` has values in the range [10, 20],
/// and there is a filter asserting that `a > 50`, then the resulting interval
/// range of `a` will be `None`.
pub interval: Option<Interval>,
/// Maximum number of distinct values this expression can produce, if known.
pub distinct_count: Precision<usize>,
}
impl ExprBoundaries {
/// Create a new `ExprBoundaries` object from column level statistics.
pub fn try_from_column(
schema: &Schema,
col_stats: &ColumnStatistics,
col_index: usize,
) -> Result<Self> {
let field = schema.fields().get(col_index).ok_or_else(|| {
internal_datafusion_err!(
"Could not create `ExprBoundaries`: in `try_from_column` `col_index`
has gone out of bounds with a value of {col_index}, the schema has {} columns.",
schema.fields.len()
)
})?;
let empty_field =
ScalarValue::try_from(field.data_type()).unwrap_or(ScalarValue::Null);
let interval = Interval::try_new(
col_stats
.min_value
.get_value()
.cloned()
.unwrap_or_else(|| empty_field.clone()),
col_stats
.max_value
.get_value()
.cloned()
.unwrap_or(empty_field),
)?;
let column = Column::new(field.name(), col_index);
Ok(ExprBoundaries {
column,
interval: Some(interval),
distinct_count: col_stats.distinct_count,
})
}
/// Create `ExprBoundaries` that represent no known bounds for all the
/// columns in `schema`
pub fn try_new_unbounded(schema: &Schema) -> Result<Vec<Self>> {
schema
.fields()
.iter()
.enumerate()
.map(|(i, field)| {
Ok(Self {
column: Column::new(field.name(), i),
interval: Some(Interval::make_unbounded(field.data_type())?),
distinct_count: Precision::Absent,
})
})
.collect()
}
}
/// Attempts to refine column boundaries and compute a selectivity value.
///
/// The function accepts boundaries of the input columns in the `context` parameter.
/// It then tries to tighten these boundaries based on the provided `expr`.
/// The resulting selectivity value is calculated by comparing the initial and final boundaries.
/// The computation assumes that the data within the column is uniformly distributed and not sorted.
///
/// # Arguments
///
/// * `context` - The context holding input column boundaries.
/// * `expr` - The expression used to shrink the column boundaries.
///
/// # Returns
///
/// * `AnalysisContext` constructed by pruned boundaries and a selectivity value.
pub fn analyze(
expr: &Arc<dyn PhysicalExpr>,
context: AnalysisContext,
schema: &Schema,
) -> Result<AnalysisContext> {
let initial_boundaries = &context.boundaries;
if initial_boundaries
.iter()
.all(|bound| bound.interval.is_none())
{
if initial_boundaries
.iter()
.any(|bound| bound.distinct_count != Precision::Exact(0))
{
return internal_err!(
"ExprBoundaries has a non-zero distinct count although it represents an empty table"
);
}
if context.selectivity != Some(0.0) {
return internal_err!(
"AnalysisContext has a non-zero selectivity although it represents an empty table"
);
}
Ok(context)
} else if initial_boundaries
.iter()
.any(|bound| bound.interval.is_none())
{
internal_err!(
"AnalysisContext is an inconsistent state. Some columns represent empty table while others don't"
)
} else {
let mut target_boundaries = context.boundaries;
let mut graph = ExprIntervalGraph::try_new(Arc::clone(expr), schema)?;
let columns = collect_columns(expr)
.into_iter()
.map(|c| Arc::new(c) as _)
.collect::<Vec<_>>();
let mut target_indices_and_boundaries = vec![];
let target_expr_and_indices = graph.gather_node_indices(columns.as_slice());
for (expr, index) in &target_expr_and_indices {
if let Some(column) = expr.as_any().downcast_ref::<Column>() {
if let Some(bound) =
target_boundaries.iter().find(|b| b.column == *column)
{
// Now, it's safe to unwrap
target_indices_and_boundaries
.push((*index, bound.interval.as_ref().unwrap().clone()));
}
}
}
match graph
.update_ranges(&mut target_indices_and_boundaries, Interval::CERTAINLY_TRUE)?
{
PropagationResult::Success => {
shrink_boundaries(graph, target_boundaries, target_expr_and_indices)
}
PropagationResult::Infeasible => {
// If the propagation result is infeasible, set intervals to None
target_boundaries
.iter_mut()
.for_each(|bound| bound.interval = None);
Ok(AnalysisContext::new(target_boundaries).with_selectivity(0.0))
}
PropagationResult::CannotPropagate => {
Ok(AnalysisContext::new(target_boundaries).with_selectivity(1.0))
}
}
}
}
/// If the `PropagationResult` indicates success, this function calculates the
/// selectivity value by comparing the initial and final column boundaries.
/// Following this, it constructs and returns a new `AnalysisContext` with the
/// updated parameters.
fn shrink_boundaries(
graph: ExprIntervalGraph,
mut target_boundaries: Vec<ExprBoundaries>,
target_expr_and_indices: Vec<(Arc<dyn PhysicalExpr>, usize)>,
) -> Result<AnalysisContext> {
let initial_boundaries = target_boundaries.clone();
target_expr_and_indices.iter().for_each(|(expr, i)| {
if let Some(column) = expr.as_any().downcast_ref::<Column>() {
if let Some(bound) = target_boundaries
.iter_mut()
.find(|bound| bound.column.eq(column))
{
bound.interval = Some(graph.get_interval(*i));
};
}
});
let selectivity = calculate_selectivity(&target_boundaries, &initial_boundaries)?;
if !(0.0..=1.0).contains(&selectivity) {
return internal_err!("Selectivity is out of limit: {}", selectivity);
}
Ok(AnalysisContext::new(target_boundaries).with_selectivity(selectivity))
}
/// This function calculates the filter predicate's selectivity by comparing
/// the initial and pruned column boundaries. Selectivity is defined as the
/// ratio of rows in a table that satisfy the filter's predicate.
fn calculate_selectivity(
target_boundaries: &[ExprBoundaries],
initial_boundaries: &[ExprBoundaries],
) -> Result<f64> {
// Since the intervals are assumed uniform and the values
// are not correlated, we need to multiply the selectivities
// of multiple columns to get the overall selectivity.
if target_boundaries.len() != initial_boundaries.len() {
return Err(internal_datafusion_err!(
"The number of columns in the initial and target boundaries should be the same"
));
}
let mut acc: f64 = 1.0;
for (initial, target) in initial_boundaries.iter().zip(target_boundaries) {
match (initial.interval.as_ref(), target.interval.as_ref()) {
(Some(initial), Some(target)) => {
acc *= cardinality_ratio(initial, target);
}
(None, Some(_)) => {
return internal_err!(
"Initial boundary cannot be None while having a Some() target boundary"
);
}
_ => return Ok(0.0),
}
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion_common::{assert_contains, DFSchema};
use datafusion_expr::{
col, execution_props::ExecutionProps, interval_arithmetic::Interval, lit, Expr,
};
use crate::{create_physical_expr, AnalysisContext};
use super::{analyze, ExprBoundaries};
fn make_field(name: &str, data_type: DataType) -> Field {
let nullable = false;
Field::new(name, data_type, nullable)
}
#[test]
fn test_analyze_boundary_exprs() {
let schema = Arc::new(Schema::new(vec![make_field("a", DataType::Int32)]));
/// Test case containing (expression tree, lower bound, upper bound)
type TestCase = (Expr, Option<i32>, Option<i32>);
let test_cases: Vec<TestCase> = vec![
// a > 10
(col("a").gt(lit(10)), Some(11), None),
// a < 20
(col("a").lt(lit(20)), None, Some(19)),
// a > 10 AND a < 20
(
col("a").gt(lit(10)).and(col("a").lt(lit(20))),
Some(11),
Some(19),
),
// a >= 10
(col("a").gt_eq(lit(10)), Some(10), None),
// a <= 20
(col("a").lt_eq(lit(20)), None, Some(20)),
// a >= 10 AND a <= 20
(
col("a").gt_eq(lit(10)).and(col("a").lt_eq(lit(20))),
Some(10),
Some(20),
),
// a > 10 AND a < 20 AND a < 15
(
col("a")
.gt(lit(10))
.and(col("a").lt(lit(20)))
.and(col("a").lt(lit(15))),
Some(11),
Some(14),
),
// (a > 10 AND a < 20) AND (a > 15 AND a < 25)
(
col("a")
.gt(lit(10))
.and(col("a").lt(lit(20)))
.and(col("a").gt(lit(15)))
.and(col("a").lt(lit(25))),
Some(16),
Some(19),
),
];
for (expr, lower, upper) in test_cases {
let boundaries = ExprBoundaries::try_new_unbounded(&schema).unwrap();
let df_schema = DFSchema::try_from(Arc::clone(&schema)).unwrap();
let physical_expr =
create_physical_expr(&expr, &df_schema, &ExecutionProps::new()).unwrap();
let analysis_result = analyze(
&physical_expr,
AnalysisContext::new(boundaries),
df_schema.as_ref(),
)
.unwrap();
let Some(actual) = &analysis_result.boundaries[0].interval else {
panic!("The analysis result should contain non-empty intervals for all columns");
};
let expected = Interval::make(lower, upper).unwrap();
assert_eq!(
&expected, actual,
"did not get correct interval for SQL expression: {expr:?}"
);
}
}
#[test]
fn test_analyze_empty_set_boundary_exprs() {
let schema = Arc::new(Schema::new(vec![make_field("a", DataType::Int32)]));
let test_cases: Vec<Expr> = vec![
// a > 10 AND a < 10
col("a").gt(lit(10)).and(col("a").lt(lit(10))),
// a > 5 AND (a < 20 OR a > 20)
// a > 10 AND a < 20
// (a > 10 AND a < 20) AND (a > 20 AND a < 30)
col("a")
.gt(lit(10))
.and(col("a").lt(lit(20)))
.and(col("a").gt(lit(20)))
.and(col("a").lt(lit(30))),
];
for expr in test_cases {
let boundaries = ExprBoundaries::try_new_unbounded(&schema).unwrap();
let df_schema = DFSchema::try_from(Arc::clone(&schema)).unwrap();
let physical_expr =
create_physical_expr(&expr, &df_schema, &ExecutionProps::new()).unwrap();
let analysis_result = analyze(
&physical_expr,
AnalysisContext::new(boundaries),
df_schema.as_ref(),
)
.unwrap();
for boundary in analysis_result.boundaries {
assert!(boundary.interval.is_none());
}
}
}
#[test]
fn test_analyze_invalid_boundary_exprs() {
let schema = Arc::new(Schema::new(vec![make_field("a", DataType::Int32)]));
let expr = col("a").lt(lit(10)).or(col("a").gt(lit(20)));
let expected_error = "OR operator cannot yet propagate true intervals";
let boundaries = ExprBoundaries::try_new_unbounded(&schema).unwrap();
let df_schema = DFSchema::try_from(Arc::clone(&schema)).unwrap();
let physical_expr =
create_physical_expr(&expr, &df_schema, &ExecutionProps::new()).unwrap();
let analysis_error = analyze(
&physical_expr,
AnalysisContext::new(boundaries),
df_schema.as_ref(),
)
.unwrap_err();
assert_contains!(analysis_error.to_string(), expected_error);
}
}