diff --git a/src/query/sql/src/planner/optimizer/ir/stats/selectivity.rs b/src/query/sql/src/planner/optimizer/ir/stats/selectivity.rs index 9f234769b4125..e38d449d3cb12 100644 --- a/src/query/sql/src/planner/optimizer/ir/stats/selectivity.rs +++ b/src/query/sql/src/planner/optimizer/ir/stats/selectivity.rs @@ -65,6 +65,7 @@ pub struct SelectivityEstimator { top_n: TopNSet, count_min_sketch: CountMinSketchSet, overrides: ColumnStatSet, + proven_empty: bool, } impl SelectivityEstimator { @@ -75,6 +76,7 @@ impl SelectivityEstimator { top_n: TopNSet::new(), count_min_sketch: CountMinSketchSet::new(), overrides: ColumnStatSet::new(), + proven_empty: cardinality == StatCardinality::Exact(0), } } @@ -88,6 +90,13 @@ impl SelectivityEstimator { self } + /// Returns true when the predicates deterministically produce no rows. + /// + /// This is deliberately stronger than an estimated cardinality of zero. + pub fn is_proven_empty(&self) -> bool { + self.proven_empty + } + fn merged_column_stats(&self) -> ColumnStatSet { let mut merged = self.column_stats.clone(); merged.extend(self.overrides.clone()); @@ -139,6 +148,7 @@ impl SelectivityEstimator { return match constant_filter_truthiness(&constant.scalar) { Some(true) => Ok(self.cardinality.value()), Some(false) => { + self.proven_empty = true; self.clear_column_stats_for_empty_result(); Ok(0.0) } @@ -159,6 +169,7 @@ impl SelectivityEstimator { }) => !domain.has_true, _ => false, }) { + self.proven_empty = true; self.clear_column_stats_for_empty_result(); return Ok(0.0); } @@ -263,6 +274,7 @@ impl SelectivityEstimator { Selectivity::Unknown => DEFAULT_SELECTIVITY, Selectivity::LowerBound => UNKNOWN_COL_STATS_FILTER_SEL_LOWER_BOUND, Selectivity::Zero => { + self.proven_empty = true; self.clear_column_stats_for_empty_result(); return 0.0; } diff --git a/src/query/sql/src/planner/optimizer/optimizers/rule/join_rules/rule_commute_join.rs b/src/query/sql/src/planner/optimizer/optimizers/rule/join_rules/rule_commute_join.rs index ca7b3b06cdbf2..b27e920ec4047 100644 --- a/src/query/sql/src/planner/optimizer/optimizers/rule/join_rules/rule_commute_join.rs +++ b/src/query/sql/src/planner/optimizer/optimizers/rule/join_rules/rule_commute_join.rs @@ -19,6 +19,7 @@ use databend_common_exception::Result; use crate::optimizer::ir::Matcher; use crate::optimizer::ir::RelExpr; use crate::optimizer::ir::SExpr; +use crate::optimizer::ir::StatInfo; use crate::optimizer::optimizers::rule::Rule; use crate::optimizer::optimizers::rule::RuleID; use crate::optimizer::optimizers::rule::TransformResult; @@ -32,6 +33,45 @@ fn contains_recursive_cte(expr: &SExpr) -> bool { || expr.children().any(contains_recursive_cte) } +fn should_commute(join_type: JoinType, left: &StatInfo, right: &StatInfo) -> bool { + if left.cardinality < right.cardinality { + return matches!( + join_type, + JoinType::Inner + | JoinType::Cross + | JoinType::Left + | JoinType::Right + | JoinType::LeftSingle + | JoinType::RightSingle + | JoinType::LeftSemi + | JoinType::RightSemi + | JoinType::LeftAnti + | JoinType::RightAnti + | JoinType::LeftMark + | JoinType::RightMark + ); + } + + if left.cardinality != right.cardinality { + return false; + } + + if left.cardinality == 0.0 && matches!(join_type, JoinType::Left | JoinType::Right) { + let left_proven_empty = left.statistics.precise_cardinality == Some(0); + let right_proven_empty = right.statistics.precise_cardinality == Some(0); + if left_proven_empty != right_proven_empty { + // The right child is the hash-build side. Prefer the input that is + // known to be empty over one whose zero cardinality is only estimated. + return left_proven_empty; + } + } + + matches!( + join_type, + JoinType::Right | JoinType::RightSingle | JoinType::RightSemi | JoinType::RightAnti + ) +} + /// Rule to apply commutativity of join operator. /// Since we will always use the right child as build side, this /// rule will help us measure which child is the better one. @@ -79,33 +119,9 @@ impl Rule for RuleCommuteJoin { let left_rel_expr = RelExpr::with_s_expr(left_child); let right_rel_expr = RelExpr::with_s_expr(right_child); - let left_card = left_rel_expr.derive_cardinality()?.cardinality; - let right_card = right_rel_expr.derive_cardinality()?.cardinality; - - let need_commute = if left_card < right_card { - matches!( - join.join_type, - JoinType::Inner - | JoinType::Cross - | JoinType::Left - | JoinType::Right - | JoinType::LeftSingle - | JoinType::RightSingle - | JoinType::LeftSemi - | JoinType::RightSemi - | JoinType::LeftAnti - | JoinType::RightAnti - | JoinType::LeftMark - | JoinType::RightMark - ) - } else if left_card == right_card { - matches!( - join.join_type, - JoinType::Right | JoinType::RightSingle | JoinType::RightSemi | JoinType::RightAnti - ) - } else { - false - }; + let left_stat = left_rel_expr.derive_cardinality()?; + let right_stat = right_rel_expr.derive_cardinality()?; + let need_commute = should_commute(join.join_type, &left_stat, &right_stat); if need_commute { // Swap the join conditions side for condition in join.equi_conditions.iter_mut() { @@ -135,3 +151,123 @@ impl Default for RuleCommuteJoin { Self::new() } } + +#[cfg(test)] +mod tests { + use databend_common_expression::Scalar; + + use super::*; + use crate::optimizer::ir::Statistics; + use crate::plans::ConstantExpr; + use crate::plans::DummyTableScan; + use crate::plans::Filter; + use crate::plans::MutationSource; + use crate::plans::ScalarExpr; + + fn empty_stat(precise: bool) -> StatInfo { + StatInfo { + cardinality: 0.0, + statistics: Statistics { + precise_cardinality: precise.then_some(0), + column_stats: Default::default(), + top_n: Default::default(), + count_min_sketch: Default::default(), + }, + } + } + + fn proven_empty_expr() -> SExpr { + SExpr::create_unary( + Filter { + predicates: vec![ScalarExpr::ConstantExpr(ConstantExpr { + span: None, + value: Scalar::Boolean(false), + })], + }, + SExpr::create_leaf(DummyTableScan::default()), + ) + } + + #[test] + fn test_outer_join_zero_tie_prefers_proven_empty_build_side() { + let proven_empty = empty_stat(true); + let estimated_empty = empty_stat(false); + + assert!(should_commute( + JoinType::Left, + &proven_empty, + &estimated_empty + )); + assert!(!should_commute( + JoinType::Left, + &estimated_empty, + &proven_empty + )); + assert!(should_commute( + JoinType::Right, + &proven_empty, + &estimated_empty + )); + assert!(!should_commute( + JoinType::Right, + &estimated_empty, + &proven_empty + )); + } + + #[test] + fn test_outer_join_zero_tie_preserves_existing_canonicalization() { + let left = empty_stat(false); + let right = empty_stat(false); + + assert!(!should_commute(JoinType::Left, &left, &right)); + assert!(should_commute(JoinType::Right, &left, &right)); + } + + #[test] + fn test_commute_join_builds_proven_empty_input() -> Result<()> { + let join = Join { + join_type: JoinType::Left, + ..Default::default() + }; + let expr = SExpr::create_binary( + join, + proven_empty_expr(), + SExpr::create_leaf(MutationSource::default()), + ); + let mut state = TransformResult::new(); + + RuleCommuteJoin::new().apply(&expr, &mut state)?; + + assert_eq!(state.results().len(), 1); + let result_join: Join = state.results()[0].plan().clone().try_into()?; + assert_eq!(result_join.join_type, JoinType::Right); + assert_eq!( + RelExpr::with_s_expr(state.results()[0].child(1)?) + .derive_cardinality()? + .statistics + .precise_cardinality, + Some(0) + ); + Ok(()) + } + + #[test] + fn test_commute_join_keeps_proven_empty_build_input() -> Result<()> { + let join = Join { + join_type: JoinType::Left, + ..Default::default() + }; + let expr = SExpr::create_binary( + join, + SExpr::create_leaf(MutationSource::default()), + proven_empty_expr(), + ); + let mut state = TransformResult::new(); + + RuleCommuteJoin::new().apply(&expr, &mut state)?; + + assert!(state.results().is_empty()); + Ok(()) + } +} diff --git a/src/query/sql/src/planner/plans/filter.rs b/src/query/sql/src/planner/plans/filter.rs index 90045dfa44528..e6c997b4eefca 100644 --- a/src/query/sql/src/planner/plans/filter.rs +++ b/src/query/sql/src/planner/plans/filter.rs @@ -98,6 +98,7 @@ impl Operator for Filter { .with_top_n(stat_info.statistics.top_n.clone()) .with_count_min_sketch(stat_info.statistics.count_min_sketch.clone()); let cardinality = sb.apply(&self.predicates)?; + let precise_cardinality = sb.is_proven_empty().then_some(0); // Derive column statistics let column_stats = if cardinality == 0.0 { HashMap::new() @@ -107,7 +108,7 @@ impl Operator for Filter { Ok(Arc::new(StatInfo { cardinality, statistics: Statistics { - precise_cardinality: None, + precise_cardinality, column_stats, top_n: Default::default(), count_min_sketch: Default::default(), @@ -115,3 +116,46 @@ impl Operator for Filter { })) } } + +#[cfg(test)] +mod tests { + use databend_common_expression::Scalar; + + use super::*; + use crate::optimizer::ir::SExpr; + use crate::plans::ConstantExpr; + use crate::plans::DummyTableScan; + use crate::plans::MutationSource; + + fn constant_filter(value: bool, input: SExpr) -> SExpr { + SExpr::create_unary( + Filter { + predicates: vec![ScalarExpr::ConstantExpr(ConstantExpr { + span: None, + value: Scalar::Boolean(value), + })], + }, + input, + ) + } + + #[test] + fn test_filter_preserves_proven_empty_cardinality() -> Result<()> { + let expr = constant_filter(false, SExpr::create_leaf(DummyTableScan::default())); + let stat = RelExpr::with_s_expr(&expr).derive_cardinality()?; + + assert_eq!(stat.cardinality, 0.0); + assert_eq!(stat.statistics.precise_cardinality, Some(0)); + Ok(()) + } + + #[test] + fn test_filter_does_not_promote_estimated_zero_to_precise() -> Result<()> { + let expr = constant_filter(true, SExpr::create_leaf(MutationSource::default())); + let stat = RelExpr::with_s_expr(&expr).derive_cardinality()?; + + assert_eq!(stat.cardinality, 0.0); + assert_eq!(stat.statistics.precise_cardinality, None); + Ok(()) + } +} diff --git a/src/query/sql/src/planner/plans/join.rs b/src/query/sql/src/planner/plans/join.rs index 56dc760ea0c8d..ec07e4f58bbb0 100644 --- a/src/query/sql/src/planner/plans/join.rs +++ b/src/query/sql/src/planner/plans/join.rs @@ -641,6 +641,8 @@ impl Join { left_stat_info: Arc, right_stat_info: Arc, ) -> Result> { + let left_proven_empty = left_stat_info.statistics.precise_cardinality == Some(0); + let right_proven_empty = right_stat_info.statistics.precise_cardinality == Some(0); let left_cardinality = left_stat_info.cardinality; let right_cardinality = right_stat_info.cardinality; let mut left_column_stats = JoinSideColumnStats::split( @@ -665,7 +667,17 @@ impl Join { let inner_join_cardinality = estimator.join_card(); let cardinality = self.join_cardinality(left_cardinality, right_cardinality, inner_join_cardinality); - + let proven_empty = match self.join_type { + JoinType::Inner | JoinType::InnerAny | JoinType::Asof | JoinType::Cross => { + left_proven_empty || right_proven_empty + } + JoinType::Left | JoinType::LeftAny | JoinType::LeftAsof => left_proven_empty, + JoinType::Right | JoinType::RightAny | JoinType::RightAsof => right_proven_empty, + JoinType::Full | JoinType::FullAsof => left_proven_empty && right_proven_empty, + JoinType::LeftSemi | JoinType::RightSemi => left_proven_empty || right_proven_empty, + JoinType::LeftSingle | JoinType::RightMark | JoinType::LeftAnti => left_proven_empty, + JoinType::RightSingle | JoinType::LeftMark | JoinType::RightAnti => right_proven_empty, + }; // Derive column statistics let column_stats = if cardinality == 0.0 { HashMap::new() @@ -689,7 +701,7 @@ impl Join { Ok(Arc::new(StatInfo { cardinality, statistics: Statistics { - precise_cardinality: None, + precise_cardinality: proven_empty.then_some(0), column_stats, top_n: Default::default(), count_min_sketch: Default::default(), @@ -1779,4 +1791,43 @@ mod tests { ); Ok(()) } + + fn empty_stat(precise: bool) -> Arc { + Arc::new(StatInfo { + cardinality: 0.0, + statistics: Statistics { + precise_cardinality: precise.then_some(0), + column_stats: Default::default(), + top_n: Default::default(), + count_min_sketch: Default::default(), + }, + }) + } + + #[test] + fn test_outer_join_propagates_proven_empty_from_preserved_side() -> Result<()> { + let proven_empty = empty_stat(true); + let estimated_empty = empty_stat(false); + + let left_join = Join { + join_type: JoinType::Left, + ..Default::default() + }; + let stat = left_join.derive_join_stats(proven_empty.clone(), estimated_empty.clone())?; + assert_eq!(stat.statistics.precise_cardinality, Some(0)); + + let stat = left_join.derive_join_stats(estimated_empty.clone(), proven_empty.clone())?; + assert_eq!(stat.statistics.precise_cardinality, None); + + let right_join = Join { + join_type: JoinType::Right, + ..Default::default() + }; + let stat = right_join.derive_join_stats(estimated_empty.clone(), proven_empty.clone())?; + assert_eq!(stat.statistics.precise_cardinality, Some(0)); + + let stat = right_join.derive_join_stats(proven_empty, estimated_empty)?; + assert_eq!(stat.statistics.precise_cardinality, None); + Ok(()) + } } diff --git a/src/query/sql/src/planner/plans/scan.rs b/src/query/sql/src/planner/plans/scan.rs index dd5aa37d380cd..5ba1184e97e6e 100644 --- a/src/query/sql/src/planner/plans/scan.rs +++ b/src/query/sql/src/planner/plans/scan.rs @@ -412,6 +412,7 @@ impl Operator for Scan { .as_ref() .and_then(|stat| stat.num_rows); + let mut proven_empty = false; let cardinality = match (precise_cardinality, &self.prewhere) { (Some(precise_cardinality), Some(prewhere)) => { // Derive cardinality @@ -422,6 +423,7 @@ impl Operator for Scan { .with_top_n(std::mem::take(&mut output_top_n)) .with_count_min_sketch(std::mem::take(&mut output_count_min_sketch)); let cardinality = sb.apply(&prewhere.predicates)?; + proven_empty = sb.is_proven_empty(); column_stats = sb.into_column_stats(); cardinality } @@ -430,7 +432,9 @@ impl Operator for Scan { }; // If prewhere is not none, we can't get precise cardinality - let precise_cardinality = if self.prewhere.is_none() && self.sample.is_none() { + let precise_cardinality = if proven_empty { + Some(0) + } else if self.prewhere.is_none() && self.sample.is_none() { precise_cardinality } else { None @@ -445,22 +449,25 @@ impl Operator for Scan { // join ordering), then suppress column statistics to prevent data // leakage through statistical inference. if self.secure_predicates.is_some() { + let mut proven_empty = precise_cardinality == Some(0); let cardinality = match &self.secure_predicates { Some(preds) if !preds.is_empty() => { let input_cardinality = precise_cardinality .map(StatCardinality::exact) .unwrap_or_else(|| StatCardinality::estimate(cardinality)); - SelectivityEstimator::new(column_stats, input_cardinality) + let mut estimator = SelectivityEstimator::new(column_stats, input_cardinality) .with_top_n(output_top_n) - .with_count_min_sketch(output_count_min_sketch) - .apply(preds)? + .with_count_min_sketch(output_count_min_sketch); + let cardinality = estimator.apply(preds)?; + proven_empty |= estimator.is_proven_empty(); + cardinality } _ => cardinality, }; return Ok(Arc::new(StatInfo { cardinality, statistics: OpStatistics { - precise_cardinality: None, + precise_cardinality: proven_empty.then_some(0), column_stats: Default::default(), top_n: Default::default(), count_min_sketch: Default::default(), diff --git a/src/query/sql/tests/it/optimizer/mod.rs b/src/query/sql/tests/it/optimizer/mod.rs index 2c4c809277bce..3b280d92c1772 100644 --- a/src/query/sql/tests/it/optimizer/mod.rs +++ b/src/query/sql/tests/it/optimizer/mod.rs @@ -17,6 +17,7 @@ mod decorrelate_correlated_aliases; mod eager_aggregation; mod join_cardinality; mod normalize_scalar; +mod outer_join_empty_cardinality; mod outer_join_to_anti; mod push_down_filter_project_set; mod selectivity; diff --git a/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.rs b/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.rs new file mode 100644 index 0000000000000..86290a8f698f8 --- /dev/null +++ b/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.rs @@ -0,0 +1,97 @@ +// Copyright 2021 Datafuse Labs +// +// Licensed 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. + +use std::collections::HashMap; + +use databend_common_catalog::TableStatistics; +use databend_common_exception::Result; +use databend_common_sql::FormatOptions; + +use crate::framework::LiteTableContext; +use crate::framework::golden::SqlTestCase; +use crate::framework::golden::open_golden_file; +use crate::framework::golden::write_case_header; + +fn empty_table_statistics() -> TableStatistics { + TableStatistics { + num_rows: Some(0), + data_size: Some(0), + data_size_compressed: None, + index_size: None, + bloom_index_size: None, + ngram_index_size: None, + inverted_index_size: None, + vector_index_size: None, + virtual_column_size: None, + number_of_blocks: Some(0), + number_of_segments: Some(0), + } +} + +async fn write_optimized_case(file: &mut impl std::io::Write, case: &SqlTestCase) -> Result<()> { + let ctx = LiteTableContext::create().await?; + ctx.register_table_sql_with_stats( + PROVEN_EMPTY_TABLE, + Some(empty_table_statistics()), + HashMap::new(), + HashMap::new(), + ) + .await?; + ctx.register_table_sql(ESTIMATED_EMPTY_TABLE).await?; + + let raw_plan = ctx.bind_sql(case.sql).await?; + let optimized_plan = ctx.optimize_plan(raw_plan.clone()).await?; + let format_options = FormatOptions { verbose: true }; + + write_case_header(file, case)?; + writeln!(file, "raw_plan:")?; + writeln!(file, "{}", raw_plan.format_indent(format_options.clone())?)?; + writeln!(file, "optimized_plan:")?; + writeln!(file, "{}", optimized_plan.format_indent(format_options)?)?; + writeln!(file)?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_outer_join_empty_cardinality_optimizer_outcomes() -> Result<()> { + let mut file = open_golden_file("optimizer", "outer_join_empty_cardinality.txt")?; + let cases = [ + SqlTestCase { + name: "commute_proven_empty_probe_to_build", + description: "When both LEFT JOIN inputs estimate to zero, commute the proven-empty probe input to the hash-build side.", + setup_sqls: &[], + sql: "SELECT p.k, e.k +FROM proven_empty AS p +LEFT JOIN estimated_empty AS e ON p.k = e.k", + }, + SqlTestCase { + name: "keep_proven_empty_build_side", + description: "When the proven-empty input is already the hash-build side, keep the LEFT JOIN orientation.", + setup_sqls: &[], + sql: "SELECT e.k, p.k +FROM estimated_empty AS e +LEFT JOIN proven_empty AS p ON e.k = p.k", + }, + ]; + + for case in &cases { + write_optimized_case(&mut file, case).await?; + } + + Ok(()) +} + +const PROVEN_EMPTY_TABLE: &str = "CREATE TABLE proven_empty(k BIGINT)"; +const ESTIMATED_EMPTY_TABLE: &str = "CREATE TABLE estimated_empty(k BIGINT)"; diff --git a/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.txt b/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.txt new file mode 100644 index 0000000000000..b1469e292a010 --- /dev/null +++ b/src/query/sql/tests/it/optimizer/outer_join_empty_cardinality.txt @@ -0,0 +1,164 @@ +=== commute_proven_empty_probe_to_build === +description: When both LEFT JOIN inputs estimate to zero, commute the proven-empty probe input to the hash-build side. +sql: SELECT p.k, e.k +FROM proven_empty AS p +LEFT JOIN estimated_empty AS e ON p.k = e.k +raw_plan: +EvalScalar +├── scalars: [proven_empty.k (#0) AS (#0), estimated_empty.k (#1) AS (#1)] +├── output columns: [estimated_empty.k (#1), proven_empty.k (#0)] +├── outer columns: [] +├── used columns: [estimated_empty.k (#1), proven_empty.k (#0)] +├── cardinality: 0.000 +├── precise cardinality: N/A +├── statistics +└── Join(Left) + ├── build keys: [estimated_empty.k (#1)] + ├── probe keys: [proven_empty.k (#0)] + ├── other filters: [] + ├── output columns: [estimated_empty.k (#1), proven_empty.k (#0)] + ├── outer columns: [] + ├── used columns: [estimated_empty.k (#1), proven_empty.k (#0)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + ├── statistics + ├── Scan + │ ├── table: default.estimated_empty (#1) + │ ├── filters: [] + │ ├── order by: [] + │ ├── limit: NONE + │ ├── output columns: [estimated_empty.k (#1)] + │ ├── outer columns: [] + │ ├── used columns: [estimated_empty.k (#1)] + │ ├── cardinality: 0.000 + │ ├── precise cardinality: N/A + │ └── statistics + └── Scan + ├── table: default.proven_empty (#0) + ├── filters: [] + ├── order by: [] + ├── limit: NONE + ├── output columns: [proven_empty.k (#0)] + ├── outer columns: [] + ├── used columns: [proven_empty.k (#0)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + └── statistics + +optimized_plan: +Join(Right) +├── build keys: [proven_empty.k (#0)] +├── probe keys: [estimated_empty.k (#1)] +├── other filters: [] +├── output columns: [estimated_empty.k (#1), proven_empty.k (#0)] +├── outer columns: [] +├── used columns: [estimated_empty.k (#1), proven_empty.k (#0)] +├── cardinality: 0.000 +├── precise cardinality: 0 +├── statistics +├── Scan +│ ├── table: default.proven_empty (#0) +│ ├── filters: [] +│ ├── order by: [] +│ ├── limit: NONE +│ ├── output columns: [proven_empty.k (#0)] +│ ├── outer columns: [] +│ ├── used columns: [proven_empty.k (#0)] +│ ├── cardinality: 0.000 +│ ├── precise cardinality: 0 +│ └── statistics +└── Scan + ├── table: default.estimated_empty (#1) + ├── filters: [] + ├── order by: [] + ├── limit: NONE + ├── output columns: [estimated_empty.k (#1)] + ├── outer columns: [] + ├── used columns: [estimated_empty.k (#1)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + └── statistics + + +=== keep_proven_empty_build_side === +description: When the proven-empty input is already the hash-build side, keep the LEFT JOIN orientation. +sql: SELECT e.k, p.k +FROM estimated_empty AS e +LEFT JOIN proven_empty AS p ON e.k = p.k +raw_plan: +EvalScalar +├── scalars: [estimated_empty.k (#0) AS (#0), proven_empty.k (#1) AS (#1)] +├── output columns: [estimated_empty.k (#0), proven_empty.k (#1)] +├── outer columns: [] +├── used columns: [estimated_empty.k (#0), proven_empty.k (#1)] +├── cardinality: 0.000 +├── precise cardinality: N/A +├── statistics +└── Join(Left) + ├── build keys: [proven_empty.k (#1)] + ├── probe keys: [estimated_empty.k (#0)] + ├── other filters: [] + ├── output columns: [estimated_empty.k (#0), proven_empty.k (#1)] + ├── outer columns: [] + ├── used columns: [estimated_empty.k (#0), proven_empty.k (#1)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + ├── statistics + ├── Scan + │ ├── table: default.proven_empty (#1) + │ ├── filters: [] + │ ├── order by: [] + │ ├── limit: NONE + │ ├── output columns: [proven_empty.k (#1)] + │ ├── outer columns: [] + │ ├── used columns: [proven_empty.k (#1)] + │ ├── cardinality: 0.000 + │ ├── precise cardinality: N/A + │ └── statistics + └── Scan + ├── table: default.estimated_empty (#0) + ├── filters: [] + ├── order by: [] + ├── limit: NONE + ├── output columns: [estimated_empty.k (#0)] + ├── outer columns: [] + ├── used columns: [estimated_empty.k (#0)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + └── statistics + +optimized_plan: +Join(Left) +├── build keys: [proven_empty.k (#1)] +├── probe keys: [estimated_empty.k (#0)] +├── other filters: [] +├── output columns: [estimated_empty.k (#0), proven_empty.k (#1)] +├── outer columns: [] +├── used columns: [estimated_empty.k (#0), proven_empty.k (#1)] +├── cardinality: 0.000 +├── precise cardinality: N/A +├── statistics +├── Scan +│ ├── table: default.proven_empty (#1) +│ ├── filters: [] +│ ├── order by: [] +│ ├── limit: NONE +│ ├── output columns: [proven_empty.k (#1)] +│ ├── outer columns: [] +│ ├── used columns: [proven_empty.k (#1)] +│ ├── cardinality: 0.000 +│ ├── precise cardinality: 0 +│ └── statistics +└── Scan + ├── table: default.estimated_empty (#0) + ├── filters: [] + ├── order by: [] + ├── limit: NONE + ├── output columns: [estimated_empty.k (#0)] + ├── outer columns: [] + ├── used columns: [estimated_empty.k (#0)] + ├── cardinality: 0.000 + ├── precise cardinality: N/A + └── statistics + + diff --git a/src/query/sql/tests/it/optimizer/selectivity_smoke.rs b/src/query/sql/tests/it/optimizer/selectivity_smoke.rs index 46afae921b9be..669bf943fd526 100644 --- a/src/query/sql/tests/it/optimizer/selectivity_smoke.rs +++ b/src/query/sql/tests/it/optimizer/selectivity_smoke.rs @@ -109,6 +109,52 @@ fn zero_cardinality_comparison_selectivity_is_finite() { assert_eq!(estimated_rows, 0.0); assert!(estimated_rows.is_finite()); + assert!(!estimator.is_proven_empty()); +} + +#[test] +fn estimated_zero_selectivity_is_not_proven_empty() { + let column_stats = ColumnStatSet::from_iter([(Symbol::new(0), ColumnStat { + min: Datum::UInt(0), + max: Datum::UInt(5), + ndv: NdvEstimate::exact(6.0), + null_count: StatCount::estimate(8.0, 8.0), + histogram: None, + })]); + let expr = function_expr("is_not_null", vec![column_expr( + "a", + 0, + DataType::Nullable(Box::new(DataType::Number(NumberDataType::UInt64))), + )]); + + let mut estimator = SelectivityEstimator::new(column_stats, StatCardinality::exact(8)); + let estimated_rows = estimator + .apply(&[expr]) + .expect("all-null input should estimate to zero rows"); + + assert_eq!(estimated_rows, 0.0); + assert!(!estimator.is_proven_empty()); +} + +#[test] +fn constant_filter_truthiness_tracks_proven_empty() { + for (scalar, expected_rows, proven_empty) in [ + (Scalar::Number(NumberScalar::UInt8(1)), 10.0, false), + (Scalar::Number(NumberScalar::Int8(-1)), 10.0, false), + (Scalar::Number(NumberScalar::UInt8(0)), 0.0, true), + (Scalar::Boolean(true), 10.0, false), + (Scalar::Boolean(false), 0.0, true), + (Scalar::Null, 0.0, true), + ] { + let mut estimator = + SelectivityEstimator::new(ColumnStatSet::new(), StatCardinality::estimate(10.0)); + let estimated_rows = estimator + .apply(&[constant_expr(scalar)]) + .expect("constant predicate should estimate"); + + assert_eq!(estimated_rows, expected_rows); + assert_eq!(estimator.is_proven_empty(), proven_empty); + } } #[test]