This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 93b2013 feat: add inexact NOT filter pushdown (#488)
93b2013 is described below
commit 93b2013f121ffb353c8fc965af798ec8aa95a995
Author: Jiwen liu <[email protected]>
AuthorDate: Thu Jul 9 10:30:29 2026 +0800
feat: add inexact NOT filter pushdown (#488)
---
.../integrations/datafusion/src/filter_pushdown.rs | 207 +++++++++++-------
.../datafusion/src/physical_plan/scan.rs | 5 +
crates/integrations/datafusion/src/table/mod.rs | 28 ++-
.../datafusion/src/variant_pushdown.rs | 6 +-
.../datafusion/tests/sql_context_tests.rs | 49 +++++
crates/paimon/src/predicate_stats.rs | 238 ++++++++++++++++++++-
crates/paimon/src/table/stats_filter.rs | 74 ++++++-
crates/paimon/src/table/table_scan.rs | 49 ++++-
8 files changed, 565 insertions(+), 91 deletions(-)
diff --git a/crates/integrations/datafusion/src/filter_pushdown.rs
b/crates/integrations/datafusion/src/filter_pushdown.rs
index 186c48e..1739a99 100644
--- a/crates/integrations/datafusion/src/filter_pushdown.rs
+++ b/crates/integrations/datafusion/src/filter_pushdown.rs
@@ -25,41 +25,48 @@ use paimon::spec::{DataField, DataType, Datum, Predicate,
PredicateBuilder};
#[derive(Debug)]
struct SingleFilterAnalysis {
translated_predicates: Vec<Predicate>,
- has_untranslated_residual: bool,
+ requires_residual: bool,
}
#[derive(Debug)]
pub(crate) struct FilterPushdownAnalysis {
pub(crate) pushed_predicate: Option<Predicate>,
- pub(crate) has_untranslated_residual: bool,
+ pub(crate) requires_residual: bool,
+}
+
+#[derive(Debug)]
+struct TranslatedPredicate {
+ predicate: Predicate,
+ requires_residual: bool,
}
fn analyze_filter(filter: &Expr, fields: &[DataField]) -> SingleFilterAnalysis
{
let translator = FilterTranslator::new(fields);
- if let Some(predicate) = translator.translate(filter) {
+ if let Some(translated) = translator.translate(filter) {
return SingleFilterAnalysis {
- translated_predicates: vec![predicate],
- has_untranslated_residual: false,
+ translated_predicates: vec![translated.predicate],
+ requires_residual: translated.requires_residual,
};
}
+ let translated = split_conjunction(filter)
+ .into_iter()
+ .filter_map(|expr| translator.translate(expr))
+ .collect::<Vec<_>>();
SingleFilterAnalysis {
- translated_predicates: split_conjunction(filter)
- .into_iter()
- .filter_map(|expr| translator.translate(expr))
- .collect(),
- has_untranslated_residual: true,
+ translated_predicates: translated.iter().map(|t|
t.predicate.clone()).collect(),
+ requires_residual: true,
}
}
pub(crate) fn analyze_filters(filters: &[Expr], fields: &[DataField]) ->
FilterPushdownAnalysis {
let mut translated_predicates = Vec::new();
- let mut has_untranslated_residual = false;
+ let mut requires_residual = false;
for filter in filters {
let analysis = analyze_filter(filter, fields);
translated_predicates.extend(analysis.translated_predicates);
- has_untranslated_residual |= analysis.has_untranslated_residual;
+ requires_residual |= analysis.requires_residual;
}
FilterPushdownAnalysis {
@@ -68,7 +75,7 @@ pub(crate) fn analyze_filters(filters: &[Expr], fields:
&[DataField]) -> FilterP
} else {
Some(Predicate::and(translated_predicates))
},
- has_untranslated_residual,
+ requires_residual,
}
}
@@ -86,8 +93,10 @@ where
F: Fn(&Predicate) -> bool,
{
let translator = FilterTranslator::new(fields);
- if let Some(predicate) = translator.translate(filter) {
- if is_exact_filter_pushdown(&predicate) {
+ if let Some(translated) = translator.translate(filter) {
+ if translated.requires_residual {
+ TableProviderFilterPushDown::Inexact
+ } else if is_exact_filter_pushdown(&translated.predicate) {
TableProviderFilterPushDown::Exact
} else {
TableProviderFilterPushDown::Inexact
@@ -130,21 +139,26 @@ impl<'a> FilterTranslator<'a> {
}
}
- fn translate(&self, expr: &Expr) -> Option<Predicate> {
+ fn translate(&self, expr: &Expr) -> Option<TranslatedPredicate> {
match expr {
Expr::BinaryExpr(binary) => self.translate_binary(binary),
- // NOT is intentionally not translated: Predicate::Not uses
two-valued
- // logic (!bool), which incorrectly returns true when the inner
predicate
- // evaluates NULL to false. Combined with Exact pushdown precision,
- // DataFusion would remove the residual filter, producing wrong
results.
- Expr::Not(_) => None,
+ // Predicate::Not uses Paimon's two-valued predicate semantics, so
+ // translating SQL NOT is only safe as Inexact pushdown: DataFusion
+ // must keep its residual filter for NULL / three-valued semantics.
+ Expr::Not(inner) => {
+ let inner = self.translate(inner.as_ref())?;
+ Some(TranslatedPredicate {
+ predicate: Predicate::negate(inner.predicate),
+ requires_residual: true,
+ })
+ }
Expr::IsNull(inner) => {
let field = self.resolve_field(inner.as_ref())?;
- self.predicate_builder.is_null(field.name()).ok()
+ self.exact(self.predicate_builder.is_null(field.name()).ok()?)
}
Expr::IsNotNull(inner) => {
let field = self.resolve_field(inner.as_ref())?;
- self.predicate_builder.is_not_null(field.name()).ok()
+
self.exact(self.predicate_builder.is_not_null(field.name()).ok()?)
}
Expr::InList(in_list) => self.translate_in_list(in_list),
Expr::Between(between) => self.translate_between(between),
@@ -154,16 +168,21 @@ impl<'a> FilterTranslator<'a> {
}
}
- fn translate_binary(&self, binary: &BinaryExpr) -> Option<Predicate> {
+ fn translate_binary(&self, binary: &BinaryExpr) ->
Option<TranslatedPredicate> {
match binary.op {
- Operator::And => Some(Predicate::and(vec![
- self.translate(binary.left.as_ref())?,
- self.translate(binary.right.as_ref())?,
- ])),
- Operator::Or => Some(Predicate::or(vec![
- self.translate(binary.left.as_ref())?,
- self.translate(binary.right.as_ref())?,
- ])),
+ Operator::And | Operator::Or => {
+ let left = self.translate(binary.left.as_ref())?;
+ let right = self.translate(binary.right.as_ref())?;
+ let predicate = if binary.op == Operator::And {
+ Predicate::and(vec![left.predicate, right.predicate])
+ } else {
+ Predicate::or(vec![left.predicate, right.predicate])
+ };
+ Some(TranslatedPredicate {
+ predicate,
+ requires_residual: left.requires_residual ||
right.requires_residual,
+ })
+ }
Operator::Eq
| Operator::NotEq
| Operator::Lt
@@ -174,21 +193,21 @@ impl<'a> FilterTranslator<'a> {
}
}
- fn translate_comparison(&self, binary: &BinaryExpr) -> Option<Predicate> {
+ fn translate_comparison(&self, binary: &BinaryExpr) ->
Option<TranslatedPredicate> {
if let Some(predicate) = self.translate_column_literal_comparison(
binary.left.as_ref(),
binary.op,
binary.right.as_ref(),
) {
- return Some(predicate);
+ return self.exact(predicate);
}
let reversed = reverse_comparison_operator(binary.op)?;
- self.translate_column_literal_comparison(
+ self.exact(self.translate_column_literal_comparison(
binary.right.as_ref(),
reversed,
binary.left.as_ref(),
- )
+ )?)
}
fn translate_column_literal_comparison(
@@ -221,7 +240,7 @@ impl<'a> FilterTranslator<'a> {
}
}
- fn translate_in_list(&self, in_list: &InList) -> Option<Predicate> {
+ fn translate_in_list(&self, in_list: &InList) ->
Option<TranslatedPredicate> {
let field = self.resolve_field(in_list.expr.as_ref())?;
let literals: Option<Vec<_>> = in_list
.list
@@ -233,16 +252,16 @@ impl<'a> FilterTranslator<'a> {
.collect();
let literals = literals?;
- if in_list.negated {
+ self.exact(if in_list.negated {
self.predicate_builder
.is_not_in(field.name(), literals)
- .ok()
+ .ok()?
} else {
- self.predicate_builder.is_in(field.name(), literals).ok()
- }
+ self.predicate_builder.is_in(field.name(), literals).ok()?
+ })
}
- fn translate_between(&self, between: &Between) -> Option<Predicate> {
+ fn translate_between(&self, between: &Between) ->
Option<TranslatedPredicate> {
let field = self.resolve_field(between.expr.as_ref())?;
let low = scalar_to_datum(
extract_scalar_literal(between.low.as_ref())?,
@@ -259,16 +278,18 @@ impl<'a> FilterTranslator<'a> {
// Parquet row filter all treat a NULL operand as non-matching (SQL
// three-valued logic), and a data-column range stays Inexact so
// DataFusion keeps the residual filter.
- if between.negated {
+ self.exact(if between.negated {
self.predicate_builder
.not_between(field.name(), low, high)
- .ok()
+ .ok()?
} else {
- self.predicate_builder.between(field.name(), low, high).ok()
- }
+ self.predicate_builder
+ .between(field.name(), low, high)
+ .ok()?
+ })
}
- fn translate_scalar_function(&self, func: &ScalarFunction) ->
Option<Predicate> {
+ fn translate_scalar_function(&self, func: &ScalarFunction) ->
Option<TranslatedPredicate> {
// DataFusion built-in UDFs surfaced from `LIKE 'x%' / '%x' / '%x%'`
// rewrites and direct `starts_with(col, 'x') / ends_with / contains`
// calls. Only `(col, literal)` shapes are handled; anything else
@@ -280,21 +301,35 @@ impl<'a> FilterTranslator<'a> {
let scalar = extract_scalar_literal(&func.args[1])?;
let datum = scalar_to_datum(scalar, field.data_type())?;
- match func.name() {
- "starts_with" => self.predicate_builder.starts_with(field.name(),
datum).ok(),
- "ends_with" => self.predicate_builder.ends_with(field.name(),
datum).ok(),
- "contains" => self.predicate_builder.contains(field.name(),
datum).ok(),
- _ => None,
- }
+ let predicate = match func.name() {
+ "starts_with" => self
+ .predicate_builder
+ .starts_with(field.name(), datum)
+ .ok()?,
+ "ends_with" => self.predicate_builder.ends_with(field.name(),
datum).ok()?,
+ "contains" => self.predicate_builder.contains(field.name(),
datum).ok()?,
+ _ => return None,
+ };
+ self.exact(predicate)
}
- fn translate_like(&self, like: &Like) -> Option<Predicate> {
- // Negated and case-insensitive (ILIKE) variants stay unsupported for
- // now: NOT-LIKE has the same NULL-semantics concern as `Expr::Not`;
- // ILIKE has no equivalent in paimon's predicate model.
- if like.negated || like.case_insensitive {
+ fn translate_like(&self, like: &Like) -> Option<TranslatedPredicate> {
+ // ILIKE has no equivalent in Paimon's predicate model.
+ if like.case_insensitive {
return None;
}
+ let predicate = self.translate_positive_like(like)?;
+ if like.negated {
+ Some(TranslatedPredicate {
+ predicate: Predicate::negate(predicate),
+ requires_residual: true,
+ })
+ } else {
+ self.exact(predicate)
+ }
+ }
+
+ fn translate_positive_like(&self, like: &Like) -> Option<Predicate> {
let field = self.resolve_field(like.expr.as_ref())?;
let scalar = extract_scalar_literal(like.pattern.as_ref())?;
let datum = scalar_to_datum(scalar, field.data_type())?;
@@ -305,6 +340,13 @@ impl<'a> FilterTranslator<'a> {
.ok()
}
+ fn exact(&self, predicate: Predicate) -> Option<TranslatedPredicate> {
+ Some(TranslatedPredicate {
+ predicate,
+ requires_residual: false,
+ })
+ }
+
fn resolve_field(&self, expr: &Expr) -> Option<&'a DataField> {
let Expr::Column(Column { name, .. }) = expr else {
return None;
@@ -682,11 +724,11 @@ mod tests {
.to_string(),
"id > 10"
);
- assert!(!analysis.has_untranslated_residual);
+ assert!(!analysis.requires_residual);
}
#[test]
- fn
test_analyze_filters_marks_partial_translation_as_untranslated_residual() {
+ fn test_analyze_filters_pushes_not_and_marks_residual_required() {
let fields = test_fields();
let filters = vec![Expr::Column(Column::from_name("dt"))
.eq(lit("2024-01-01"))
@@ -700,21 +742,27 @@ mod tests {
.pushed_predicate
.expect("supported conjunct should still translate")
.to_string(),
- "dt = '2024-01-01'"
+ "(dt = '2024-01-01' AND NOT (hr = 10))"
);
- assert!(analysis.has_untranslated_residual);
+ assert!(analysis.requires_residual);
}
#[test]
- fn
test_analyze_filters_marks_unsupported_filter_as_untranslated_residual() {
+ fn test_analyze_filters_pushes_not_filter_and_marks_residual_required() {
let fields = test_fields();
let filters = vec![Expr::Not(Box::new(
Expr::Column(Column::from_name("dt")).eq(lit("2024-01-01")),
))];
let analysis = analyze_filters(&filters, &fields);
- assert!(analysis.pushed_predicate.is_none());
- assert!(analysis.has_untranslated_residual);
+ assert_eq!(
+ analysis
+ .pushed_predicate
+ .expect("NOT partition predicate should translate inexactly")
+ .to_string(),
+ "NOT (dt = '2024-01-01')"
+ );
+ assert!(analysis.requires_residual);
}
#[test]
@@ -805,20 +853,22 @@ mod tests {
}
#[test]
- fn test_translate_not_is_not_supported() {
+ fn test_translate_not_pushes_negated_predicate() {
let fields = test_fields();
let filter = Expr::Not(Box::new(
Expr::Column(Column::from_name("dt")).eq(lit("2024-01-01")),
));
- assert!(
- build_pushed_predicate(&[filter], &fields).is_none(),
- "NOT expressions should not translate due to NULL semantics"
+ assert_eq!(
+ build_pushed_predicate(&[filter], &fields)
+ .expect("NOT should translate as an inexact pushed predicate")
+ .to_string(),
+ "NOT (dt = '2024-01-01')"
);
}
#[test]
- fn test_classify_not_filter_as_unsupported() {
+ fn test_classify_not_filter_as_inexact_even_when_partition_only() {
let fields = test_fields();
let filter = Expr::Not(Box::new(
Expr::Column(Column::from_name("dt")).eq(lit("2024-01-01")),
@@ -826,7 +876,7 @@ mod tests {
assert_eq!(
classify_filter_pushdown(&filter, &fields,
is_exact_filter_pushdown),
- TableProviderFilterPushDown::Unsupported
+ TableProviderFilterPushDown::Inexact
);
}
@@ -987,11 +1037,18 @@ mod tests {
}
#[test]
- fn test_translate_negated_like_falls_open() {
+ fn test_translate_negated_like_pushes_inexact_not() {
let fields = test_fields();
- assert!(
- build_pushed_predicate(&[like_filter("a%", true, false)],
&fields).is_none(),
- "NOT LIKE must not translate (NULL semantics)"
+ let predicate = build_pushed_predicate(&[like_filter("a%", true,
false)], &fields)
+ .expect("NOT LIKE should translate as inexact NOT over LIKE");
+ assert_eq!(predicate.to_string(), "NOT (dt STARTS_WITH 'a')");
+ assert_eq!(
+ classify_filter_pushdown(
+ &like_filter("a%", true, false),
+ &fields,
+ is_exact_filter_pushdown
+ ),
+ TableProviderFilterPushDown::Inexact
);
}
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index bcde017..a0d9f3c 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -108,6 +108,11 @@ impl PaimonTableScan {
self.pushed_predicate.as_ref()
}
+ #[cfg(test)]
+ pub(crate) fn filter_exact(&self) -> bool {
+ self.filter_exact
+ }
+
pub fn limit(&self) -> Option<usize> {
self.limit
}
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index ba0a119..b10eb26 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -363,7 +363,7 @@ impl TableProvider for PaimonTableProvider {
if let Some(filter) = filter_analysis.pushed_predicate.clone() {
read_builder.with_filter(filter);
}
- let pushed_limit = limit.filter(|_|
!filter_analysis.has_untranslated_residual);
+ let pushed_limit = limit.filter(|_|
!filter_analysis.requires_residual);
if let Some(limit) = pushed_limit {
read_builder.with_limit(limit);
}
@@ -377,7 +377,7 @@ impl TableProvider for PaimonTableProvider {
.map_err(to_datafusion_error)?;
let target = state.config_options().execution.target_partitions;
- let filter_exact = !filter_analysis.has_untranslated_residual
+ let filter_exact = !filter_analysis.requires_residual
&& filter_analysis
.pushed_predicate
.as_ref()
@@ -780,7 +780,7 @@ mod tests {
}
#[tokio::test]
- async fn
test_scan_partially_translated_filter_keeps_partition_pruning_but_skips_limit_hint()
{
+ async fn
test_scan_partially_translated_not_filter_prunes_partitions_but_skips_limit_hint()
{
let provider = create_provider("multi_partitioned_log_table").await;
let filter = col("dt")
.eq(lit("2024-01-01"))
@@ -794,10 +794,7 @@ mod tests {
assert_eq!(scan.limit(), None);
assert_eq!(
extract_dt_hr_partition_set(scan.planned_partitions()),
- BTreeSet::from([
- ("2024-01-01".to_string(), 10),
- ("2024-01-01".to_string(), 20),
- ]),
+ BTreeSet::from([("2024-01-01".to_string(), 20)]),
);
assert_eq!(
scan.planned_partitions()
@@ -833,6 +830,23 @@ mod tests {
assert_eq!(scan.pushed_predicate(), Some(&expected));
}
+ #[tokio::test]
+ async fn test_scan_pushes_not_as_inexact_and_skips_limit_hint() {
+ let provider = data_evolution_projection_pruning_provider().await;
+ let filter = Expr::Not(Box::new(col("id").eq(lit(1))));
+ let plan = plan_scan(&provider, vec![filter.clone()], Some(1)).await;
+ let scan = plan
+ .downcast_ref::<PaimonTableScan>()
+ .expect("Expected PaimonTableScan");
+
+ let expected = build_pushed_predicate(&[filter],
provider.table().schema().fields())
+ .expect("NOT filter should translate as inexact pushdown");
+
+ assert_eq!(scan.pushed_predicate(), Some(&expected));
+ assert!(!scan.filter_exact());
+ assert_eq!(scan.limit(), None);
+ }
+
#[tokio::test]
async fn test_scan_applies_projection_to_data_evolution_planning() {
let provider = data_evolution_projection_pruning_provider().await;
diff --git a/crates/integrations/datafusion/src/variant_pushdown.rs
b/crates/integrations/datafusion/src/variant_pushdown.rs
index 1aff558..c33339f 100644
--- a/crates/integrations/datafusion/src/variant_pushdown.rs
+++ b/crates/integrations/datafusion/src/variant_pushdown.rs
@@ -214,9 +214,7 @@ impl ExtensionPlanner for VariantExtractionExtensionPlanner
{
if let Some(filter) = filter_analysis.pushed_predicate.clone() {
read_builder.with_filter(filter);
}
- let pushed_limit = node
- .fetch
- .filter(|_| !filter_analysis.has_untranslated_residual);
+ let pushed_limit = node.fetch.filter(|_|
!filter_analysis.requires_residual);
if let Some(limit) = pushed_limit {
read_builder.with_limit(limit);
}
@@ -236,7 +234,7 @@ impl ExtensionPlanner for VariantExtractionExtensionPlanner
{
.map(Arc::from)
.collect()
};
- let filter_exact = !filter_analysis.has_untranslated_residual
+ let filter_exact = !filter_analysis.requires_residual
&& filter_analysis
.pushed_predicate
.as_ref()
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index 1d5ea34..ef9d4eb 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -19,6 +19,7 @@
use std::sync::Arc;
+use datafusion::arrow::array::Array;
use datafusion::catalog::CatalogProvider;
use datafusion::datasource::MemTable;
use paimon::catalog::Identifier;
@@ -843,6 +844,54 @@ async fn test_register_temp_table_database_qualified() {
assert_eq!(total_rows, 2);
}
+#[tokio::test]
+async fn test_not_filter_pushdown_keeps_sql_null_semantics() {
+ let (_tmp, catalog) = create_test_env();
+ let ctx = create_sql_context(catalog.clone()).await;
+
+ catalog
+ .create_database("my_db", false, Default::default())
+ .await
+ .unwrap();
+ ctx.sql("CREATE TABLE paimon.my_db.t (id INT, name STRING)")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ ctx.sql("INSERT INTO paimon.my_db.t VALUES (1, 'one'), (2, 'two'), (NULL,
'nil')")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let batches = ctx
+ .sql("SELECT id FROM paimon.my_db.t WHERE NOT (id = 1) ORDER BY id")
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+
+ let values = batches
+ .iter()
+ .flat_map(|batch| {
+ let ids = batch
+ .column_by_name("id")
+ .and_then(|column| {
+ column
+ .as_any()
+ .downcast_ref::<datafusion::arrow::array::Int32Array>()
+ })
+ .expect("id column");
+ (0..ids.len()).map(|row| ids.value(row)).collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+
+ assert_eq!(values, vec![2]);
+}
+
#[tokio::test]
async fn test_register_temp_table_bare() {
let (_tmp, catalog) = create_test_env();
diff --git a/crates/paimon/src/predicate_stats.rs
b/crates/paimon/src/predicate_stats.rs
index d293d5c..846970b 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -207,6 +207,104 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
}
}
+pub(crate) fn data_leaf_must_match<T: StatsAccessor>(
+ index: usize,
+ stats_data_type: &DataType,
+ predicate_data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+ stats: &T,
+) -> bool {
+ let row_count = stats.row_count();
+ if row_count <= 0 {
+ return false;
+ }
+
+ let null_count = stats.null_count(index);
+ match op {
+ PredicateOperator::IsNull => return null_count == Some(row_count),
+ PredicateOperator::IsNotNull => return null_count == Some(0),
+ _ => {
+ if null_count != Some(0) {
+ return false;
+ }
+ }
+ }
+
+ let min_value = match stats
+ .min_value(index, stats_data_type)
+ .and_then(|datum| coerce_stats_datum_for_predicate(datum,
predicate_data_type))
+ {
+ Some(value) => value,
+ None => return false,
+ };
+ let max_value = match stats
+ .max_value(index, stats_data_type)
+ .and_then(|datum| coerce_stats_datum_for_predicate(datum,
predicate_data_type))
+ {
+ Some(value) => value,
+ None => return false,
+ };
+ if !matches!(
+ min_value.partial_cmp(&max_value),
+ Some(Ordering::Less | Ordering::Equal)
+ ) {
+ return false;
+ }
+
+ match op {
+ PredicateOperator::Eq => literals
+ .first()
+ .is_some_and(|literal| min_value == *literal && max_value ==
*literal),
+ PredicateOperator::NotEq => literals.first().is_some_and(|literal| {
+ matches!(literal.partial_cmp(&min_value), Some(Ordering::Less))
+ || matches!(literal.partial_cmp(&max_value),
Some(Ordering::Greater))
+ }),
+ PredicateOperator::Lt => literals
+ .first()
+ .is_some_and(|literal| matches!(max_value.partial_cmp(literal),
Some(Ordering::Less))),
+ PredicateOperator::LtEq => literals.first().is_some_and(|literal| {
+ matches!(
+ max_value.partial_cmp(literal),
+ Some(Ordering::Less | Ordering::Equal)
+ )
+ }),
+ PredicateOperator::Gt => literals.first().is_some_and(|literal| {
+ matches!(min_value.partial_cmp(literal), Some(Ordering::Greater))
+ }),
+ PredicateOperator::GtEq => literals.first().is_some_and(|literal| {
+ matches!(
+ min_value.partial_cmp(literal),
+ Some(Ordering::Greater | Ordering::Equal)
+ )
+ }),
+ PredicateOperator::In => min_value == max_value &&
literals.contains(&min_value),
+ PredicateOperator::Between => {
+ let (Some(low), Some(high)) = (literals.first(), literals.get(1))
else {
+ return false;
+ };
+ let min_ge_low = !matches!(min_value.partial_cmp(low),
Some(Ordering::Less));
+ let max_le_high = !matches!(max_value.partial_cmp(high),
Some(Ordering::Greater));
+ min_ge_low && max_le_high
+ }
+ PredicateOperator::NotBetween => {
+ let (Some(low), Some(high)) = (literals.first(), literals.get(1))
else {
+ return false;
+ };
+ let max_lt_low = matches!(max_value.partial_cmp(low),
Some(Ordering::Less));
+ let min_gt_high = matches!(min_value.partial_cmp(high),
Some(Ordering::Greater));
+ max_lt_low || min_gt_high
+ }
+ PredicateOperator::IsNull
+ | PredicateOperator::IsNotNull
+ | PredicateOperator::NotIn
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like => false,
+ }
+}
+
/// Return the literal prefix of a SQL LIKE pattern up to the first unescaped
/// `%` or `_`. A backslash escapes the next character (which is appended
/// literally, mirroring arrow's `like` kernel); a trailing backslash is a
@@ -252,6 +350,10 @@ pub(crate) fn missing_field_may_match(op:
PredicateOperator, row_count: i64) ->
matches!(op, PredicateOperator::IsNull)
}
+pub(crate) fn missing_field_must_match(op: PredicateOperator, row_count: i64)
-> bool {
+ row_count > 0 && matches!(op, PredicateOperator::IsNull)
+}
+
/// Stats-prune `field BETWEEN low AND high` (and its negation) by treating it
/// as the conjunction `field >= low AND field <= high`:
/// * `Between` may match iff the file's `[min, max]` overlaps `[low, high]`.
@@ -321,7 +423,9 @@ fn predicate_may_match_with_schema<T: StatsAccessor>(
Predicate::Or(children) => children
.iter()
.any(|child| predicate_may_match_with_schema(child, stats,
field_mapping, file_fields)),
- Predicate::Not(_) => true,
+ Predicate::Not(inner) => {
+ !predicate_must_match_with_schema(inner, stats, field_mapping,
file_fields)
+ }
Predicate::Leaf {
index,
data_type,
@@ -347,6 +451,49 @@ fn predicate_may_match_with_schema<T: StatsAccessor>(
}
}
+fn predicate_must_match_with_schema<T: StatsAccessor>(
+ predicate: &Predicate,
+ stats: &T,
+ field_mapping: &[Option<usize>],
+ file_fields: &[DataField],
+) -> bool {
+ match predicate {
+ Predicate::AlwaysTrue => stats.row_count() > 0,
+ Predicate::AlwaysFalse => false,
+ Predicate::And(children) => children.iter().all(|child| {
+ predicate_must_match_with_schema(child, stats, field_mapping,
file_fields)
+ }),
+ Predicate::Or(children) => children.iter().any(|child| {
+ predicate_must_match_with_schema(child, stats, field_mapping,
file_fields)
+ }),
+ Predicate::Not(inner) => {
+ !predicate_may_match_with_schema(inner, stats, field_mapping,
file_fields)
+ }
+ Predicate::Leaf {
+ index,
+ data_type,
+ op,
+ literals,
+ ..
+ } => match field_mapping.get(*index).copied().flatten() {
+ Some(file_index) => {
+ let Some(file_field) = file_fields.get(file_index) else {
+ return false;
+ };
+ data_leaf_must_match(
+ file_index,
+ file_field.data_type(),
+ data_type,
+ *op,
+ literals,
+ stats,
+ )
+ }
+ None => missing_field_must_match(*op, stats.row_count()),
+ },
+ }
+}
+
fn coerce_stats_datum_for_predicate(datum: Datum, predicate_data_type:
&DataType) -> Option<Datum> {
match (datum, predicate_data_type) {
(datum @ Datum::Bool(_), DataType::Boolean(_))
@@ -613,6 +760,95 @@ mod tests {
));
}
+ #[test]
+ fn not_predicate_prunes_only_when_inner_must_match() {
+ let dt = DataType::Int(IntType::new());
+ let fields = vec![DataField::new(0, "id".to_string(), dt.clone())];
+ let mapping = vec![Some(0)];
+ let stats = MockStats {
+ row_count: 5,
+ null_count: Some(0),
+ min: Some(Datum::Int(10)),
+ max: Some(Datum::Int(10)),
+ };
+ let predicate = Predicate::negate(Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: dt,
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Int(10)],
+ });
+
+ assert!(!predicates_may_match_with_schema(
+ &[predicate],
+ &stats,
+ &mapping,
+ &fields,
+ ));
+ }
+
+ #[test]
+ fn not_predicate_fails_open_when_nulls_or_stats_make_inner_uncertain() {
+ let dt = DataType::Int(IntType::new());
+ let fields = vec![DataField::new(0, "id".to_string(), dt.clone())];
+ let mapping = vec![Some(0)];
+ let predicate = Predicate::negate(Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: dt,
+ op: PredicateOperator::Gt,
+ literals: vec![Datum::Int(5)],
+ });
+
+ let with_nulls = MockStats {
+ row_count: 5,
+ null_count: Some(1),
+ min: Some(Datum::Int(10)),
+ max: Some(Datum::Int(20)),
+ };
+ assert!(predicates_may_match_with_schema(
+ std::slice::from_ref(&predicate),
+ &with_nulls,
+ &mapping,
+ &fields,
+ ));
+
+ let missing_stats = MockStats {
+ row_count: 5,
+ null_count: Some(0),
+ min: None,
+ max: Some(Datum::Int(20)),
+ };
+ assert!(predicates_may_match_with_schema(
+ &[predicate],
+ &missing_stats,
+ &mapping,
+ &fields,
+ ));
+ }
+
+ #[test]
+ fn not_predicate_prunes_inner_range_that_must_match() {
+ let dt = DataType::Int(IntType::new());
+ let fields = vec![DataField::new(0, "id".to_string(), dt.clone())];
+ let mapping = vec![Some(0)];
+ let stats = int_stats(10, 20);
+ let predicate = Predicate::negate(Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: dt,
+ op: PredicateOperator::Between,
+ literals: vec![Datum::Int(0), Datum::Int(100)],
+ });
+
+ assert!(!predicates_may_match_with_schema(
+ &[predicate],
+ &stats,
+ &mapping,
+ &fields,
+ ));
+ }
+
#[test]
fn between_with_all_null_file_is_pruned() {
let dt = DataType::Int(IntType::new());
diff --git a/crates/paimon/src/table/stats_filter.rs
b/crates/paimon/src/table/stats_filter.rs
index 63cf520..2eb18e5 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -20,7 +20,8 @@
use super::Table;
use crate::arrow::schema_evolution::create_index_mapping;
use crate::predicate_stats::{
- data_leaf_may_match, missing_field_may_match,
predicates_may_match_with_schema, StatsAccessor,
+ data_leaf_may_match, data_leaf_must_match, missing_field_may_match,
missing_field_must_match,
+ predicates_may_match_with_schema, StatsAccessor,
};
use crate::spec::{extract_datum, BinaryRow, DataField, DataFileMeta, DataType,
Datum, Predicate};
use std::collections::HashMap;
@@ -391,7 +392,13 @@ fn data_evolution_predicate_may_match(
row_count,
)
}),
- Predicate::Not(_) => true,
+ Predicate::Not(inner) => !data_evolution_predicate_must_match(
+ inner,
+ table_fields,
+ field_sources,
+ file_stats,
+ row_count,
+ ),
Predicate::Leaf {
index,
data_type,
@@ -420,6 +427,69 @@ fn data_evolution_predicate_may_match(
}
}
+fn data_evolution_predicate_must_match(
+ predicate: &Predicate,
+ table_fields: &[DataField],
+ field_sources: &[Option<(usize, usize)>],
+ file_stats: &[FileStatsRows],
+ row_count: i64,
+) -> bool {
+ match predicate {
+ Predicate::AlwaysTrue => row_count > 0,
+ Predicate::AlwaysFalse => false,
+ Predicate::And(children) => children.iter().all(|child| {
+ data_evolution_predicate_must_match(
+ child,
+ table_fields,
+ field_sources,
+ file_stats,
+ row_count,
+ )
+ }),
+ Predicate::Or(children) => children.iter().any(|child| {
+ data_evolution_predicate_must_match(
+ child,
+ table_fields,
+ field_sources,
+ file_stats,
+ row_count,
+ )
+ }),
+ Predicate::Not(inner) => !data_evolution_predicate_may_match(
+ inner,
+ table_fields,
+ field_sources,
+ file_stats,
+ row_count,
+ ),
+ Predicate::Leaf {
+ index,
+ data_type,
+ op,
+ literals,
+ ..
+ } => {
+ let Some(source) = field_sources.get(*index).copied().flatten()
else {
+ return missing_field_must_match(*op, row_count);
+ };
+ let (file_idx, field_index) = source;
+ let stats = &file_stats[file_idx];
+ let stats_data_type = table_fields
+ .get(*index)
+ .map(|f| f.data_type())
+ .unwrap_or(data_type);
+ data_leaf_must_match(
+ field_index,
+ stats_data_type,
+ data_type,
+ *op,
+ literals,
+ stats,
+ )
+ }
+ }
+}
+
/// Groups data files by overlapping `row_id_range` for data evolution.
///
/// Files are sorted by `(first_row_id, -max_sequence_number)`. Files whose
row ID ranges
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 71ad467..08b225f 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -2632,7 +2632,30 @@ mod tests {
}
#[test]
- fn test_data_file_matches_not_fails_open() {
+ fn test_data_file_matches_not_prunes_when_inner_must_match() {
+ let fields = int_field();
+ let file = test_data_file_meta(
+ int_stats_row(Some(10)),
+ int_stats_row(Some(10)),
+ vec![Some(0)],
+ 5,
+ );
+ let predicate = Predicate::negate(
+ PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(10))
+ .unwrap(),
+ );
+
+ assert!(!data_file_matches_predicates(
+ &file,
+ &[predicate],
+ TEST_SCHEMA_ID,
+ &test_schema_fields(),
+ ));
+ }
+
+ #[test]
+ fn test_data_file_matches_not_fails_open_when_inner_not_certain() {
let fields = int_field();
let file = test_data_file_meta(
int_stats_row(Some(10)),
@@ -2642,7 +2665,7 @@ mod tests {
);
let predicate = Predicate::negate(
PredicateBuilder::new(&fields)
- .less_than("id", Datum::Int(5))
+ .equal("id", Datum::Int(10))
.unwrap(),
);
@@ -2698,6 +2721,28 @@ mod tests {
));
}
+ #[test]
+ fn test_data_evolution_group_matches_not_prunes_when_inner_must_match() {
+ let fields = int_field();
+ let file = test_data_file_meta(
+ int_stats_row(Some(10)),
+ int_stats_row(Some(10)),
+ vec![Some(0)],
+ 5,
+ );
+ let predicate = Predicate::negate(
+ PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(10))
+ .unwrap(),
+ );
+
+ assert!(!data_evolution_group_matches_predicates(
+ &[file],
+ &[predicate],
+ &fields,
+ ));
+ }
+
#[test]
fn test_data_file_matches_corrupt_stats_fails_open() {
let fields = int_field();