Repository navigation
Parquet stats pruning: IS NULL/IS NOT NULL Simplification for Literal Arguments #19092
Copy link
Copy link
Open
Description
This is a dump of a conversation with Claude Code
Problem
When a predicate like b IS NOT NULL OR a = 24 is rewritten for a file schema where column b doesn't exist, the b IS NOT NULL becomes IS NOT NULL(NULL literal). This should simplify to false, allowing the predicate to become a = 24 for proper row group pruning.
Currently:
PhysicalExprSimplifierdoesn't simplifyIS NULL(literal)orIS NOT NULL(literal)build_predicate_expressiontreatsIS NOT NULL(literal)as "unhandled" → returnstrue- Result:
true OR (pruning_expr)=true→ no pruning possible
Solution
Implement both fixes so that IS NOT NULL(lit(1)) and similar expressions benefit from simplification at multiple levels.
Change 1: Add IS NULL/IS NOT NULL Simplification to PhysicalExprSimplifier
File: datafusion/physical-expr/src/simplifier/mod.rs
Modifications:
-
Add new module import:
pub mod is_null;
-
Update
f_upmethod to chain the new simplification:fn f_up(&mut self, node: Self::Node) -> Result<Transformed<Self::Node>> { let rewritten = simplify_not_expr(&node, self.schema)? .transform_data(|node| is_null::simplify_is_null_expr(&node))? .transform_data(|node| { unwrap_cast::unwrap_cast_in_comparison(node, self.schema) })?; Ok(rewritten) }
New File: datafusion/physical-expr/src/simplifier/is_null.rs
Create a new module following the pattern of not.rs:
//! Simplify IS NULL and IS NOT NULL expressions on literals
//!
//! - IS NULL(literal) -> true/false based on literal.value().is_null()
//! - IS NOT NULL(literal) -> true/false based on !literal.value().is_null()
use std::sync::Arc;
use datafusion_common::{tree_node::Transformed, Result, ScalarValue};
use crate::expressions::{lit, IsNullExpr, IsNotNullExpr, Literal};
use crate::PhysicalExpr;
pub fn simplify_is_null_expr(
expr: &Arc<dyn PhysicalExpr>,
) -> Result<Transformed<Arc<dyn PhysicalExpr>>> {
// Handle IS NULL(literal)
if let Some(is_null_expr) = expr.as_any().downcast_ref::<IsNullExpr>() {
if let Some(literal) = is_null_expr.arg().as_any().downcast_ref::<Literal>() {
let result = literal.value().is_null();
return Ok(Transformed::yes(lit(ScalarValue::Boolean(Some(result)))));
}
}
// Handle IS NOT NULL(literal)
if let Some(is_not_null_expr) = expr.as_any().downcast_ref::<IsNotNullExpr>() {
if let Some(literal) = is_not_null_expr.arg().as_any().downcast_ref::<Literal>() {
let result = !literal.value().is_null();
return Ok(Transformed::yes(lit(ScalarValue::Boolean(Some(result)))));
}
}
Ok(Transformed::no(Arc::clone(expr)))
}Tests to Add (in mod.rs)
#[test]
fn test_simplify_is_null_literal() -> Result<()> {
let schema = test_schema();
let mut simplifier = PhysicalExprSimplifier::new(&schema);
// IS NULL(1) -> false
let is_null_expr = Arc::new(IsNullExpr::new(lit(ScalarValue::Int32(Some(1)))));
let result = simplifier.simplify(is_null_expr)?;
assert_eq!(as_literal(&result).value(), &ScalarValue::Boolean(Some(false)));
// IS NULL(NULL) -> true
let is_null_expr = Arc::new(IsNullExpr::new(lit(ScalarValue::Int32(None))));
let result = simplifier.simplify(is_null_expr)?;
assert_eq!(as_literal(&result).value(), &ScalarValue::Boolean(Some(true)));
Ok(())
}
#[test]
fn test_simplify_is_not_null_literal() -> Result<()> {
let schema = test_schema();
let mut simplifier = PhysicalExprSimplifier::new(&schema);
// IS NOT NULL(1) -> true
let expr = Arc::new(IsNotNullExpr::new(lit(ScalarValue::Int32(Some(1)))));
let result = simplifier.simplify(expr)?;
assert_eq!(as_literal(&result).value(), &ScalarValue::Boolean(Some(true)));
// IS NOT NULL(NULL) -> false
let expr = Arc::new(IsNotNullExpr::new(lit(ScalarValue::Int32(None))));
let result = simplifier.simplify(expr)?;
assert_eq!(as_literal(&result).value(), &ScalarValue::Boolean(Some(false)));
Ok(())
}Change 2: Handle Literal Arguments in build_predicate_expression
File: datafusion/pruning/src/pruning_predicate.rs
Modify build_predicate_expression function (~line 1415-1427):
Before:
if let Some(is_null) = expr_any.downcast_ref::<phys_expr::IsNullExpr>() {
return build_is_null_column_expr(is_null.arg(), schema, required_columns, false)
.unwrap_or_else(|| unhandled_hook.handle(expr));
}
if let Some(is_not_null) = expr_any.downcast_ref::<phys_expr::IsNotNullExpr>() {
return build_is_null_column_expr(
is_not_null.arg(),
schema,
required_columns,
true,
)
.unwrap_or_else(|| unhandled_hook.handle(expr));
}After:
if let Some(is_null) = expr_any.downcast_ref::<phys_expr::IsNullExpr>() {
// If argument is a literal, evaluate directly
if let Some(literal) = is_null.arg().as_any().downcast_ref::<phys_expr::Literal>() {
let result = literal.value().is_null();
return Arc::new(phys_expr::Literal::new(ScalarValue::Boolean(Some(result))));
}
return build_is_null_column_expr(is_null.arg(), schema, required_columns, false)
.unwrap_or_else(|| unhandled_hook.handle(expr));
}
if let Some(is_not_null) = expr_any.downcast_ref::<phys_expr::IsNotNullExpr>() {
// If argument is a literal, evaluate directly
if let Some(literal) = is_not_null.arg().as_any().downcast_ref::<phys_expr::Literal>() {
let result = !literal.value().is_null();
return Arc::new(phys_expr::Literal::new(ScalarValue::Boolean(Some(result))));
}
return build_is_null_column_expr(
is_not_null.arg(),
schema,
required_columns,
true,
)
.unwrap_or_else(|| unhandled_hook.handle(expr));
}Files to Modify
datafusion/physical-expr/src/simplifier/mod.rs- Add is_null module import and chain simplificationdatafusion/physical-expr/src/simplifier/is_null.rs- New file for IS NULL/IS NOT NULL simplificationdatafusion/pruning/src/pruning_predicate.rs- Handle literal arguments in build_predicate_expression
Testing
-
Run the original failing test:
cargo test --package datafusion --test core_integration --all-features -- schema_adapter::schema_adapter_integration_tests::test_parquet_missing_column --exact --nocapture -
Run simplifier tests:
cargo test --package datafusion-physical-expr --lib simplifier -
Run pruning predicate tests:
cargo test --package datafusion-pruning --lib
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
xudong963 commentedon Dec 5, 2025
Why can
bbe allowed not to exist? If so, there should be a early error report for the predicate?adriangb commentedon Dec 5, 2025
Isn't that just normal schema evolution? A column
bwas added so older files won't have it.shashidhar-bm commentedon Dec 20, 2025
take
pepijnve commentedon Dec 21, 2025
@adriangb this seems like something
simplify_const_exprshould already be able to do. Any idea why it isn't in your case? When I just tested the proposed unit tests onmain, they already pass.adriangb commentedon Dec 21, 2025
This is after per file expression rewrites eg if the column is missing
pepijnve commentedon Dec 21, 2025
Wouldn’t another round of the generic simplify after that transform has happened also do the trick? Or is there too much overhead in doing that? An specialisation of const evaluation just for
is nullseems a bit ad hoc.adriangb commentedon Dec 21, 2025
Yes a generic round would work but afaik we don’t have this simplification at the physical expression simplification layer
adriangb commentedon Dec 21, 2025
(Maybe I’m wrong about that but that was why I opened the issue)
pepijnve commentedon Dec 21, 2025
The proposed unit tests already pass, so it's there to some extent at least.
adriangb commentedon Dec 21, 2025
Let me confirm and we can close the issue 🤣
pepijnve commentedon Dec 21, 2025
I did a quick, slightly more complex test.
NULL IS NOT NULL OR a = 24simplifies tofalse OR a = 24, but that isn't simplified further toa = 24. Could that redundantor falseget in the way of pruning?shashidhar-bm commentedon Dec 21, 2025
I tried a test to verify the value of
tf.transformedfor the expressionNULL IS NOT NULL OR a = 24, and it returnedfalse. Whentf.transformed == false, simplification is skipped in the original code (line 479:expr = tf.data),adriangb commentedon Dec 21, 2025
Ok I've done a bit of digging.
Firstly I apologize for making a confusing issue. I ran into this while working on a bigger change and wanted to document it to tackle later but evidently even though I included a lot of detail I didn't include the right detail.
I'm still not sure what the original e2e reproduction I had for this was, but since I opened this on December 4th it's possible it was fixed by #19130 (const simplifier) which was merged a couple days later, or by #19111 merged after that and changed the structure that @ShashidharM0118 points out in #19434 (comment) would have also caused this issue.
I did find something interesting: #19136 introduced a new opportunity for optimization via simplification. If we have a constant column say
a = 2and the predicatea is not nullwe replace that to2 is not null. That will get simplified later for the scan but as far as I can tell it does not get simplified before being fed intoFilePruner.FilePruneralso does not simplify the output of any dynamic filters. So I think if we added a simplifier pass inFilePrunerwe'd get some extra pruning in the case ofconstant_col is null.adriangb commentedon Dec 21, 2025
Here's the reproducer for that:
CREATE EXTERNAL TABLE t ( x INT ) STORED AS PARQUET LOCATION '/tmp/test-data/'; INSERT INTO t VALUES (1), (1); EXPLAIN ANALYZE SELECT COUNT(*) FROM t WHERE x IS NULL;As you can see if has
files_ranges_pruned_statistics=1 total → 1 matched. Adding a simplifier forFilePrunerI seefiles_ranges_pruned_statistics=1 total → 0 matched.The diff is:
pepijnve commentedon Dec 21, 2025
I was tinkering a bit out of curiosity. With the addition of the simplification in the collapsed details to the physical expression simplifier
Details
this test case passes, so
NULL IS NOT NULL or c1 = 24gets simplified toc1 = 24. Not sure how useful this is, but seems like it might be nice to strip away cruft after const evaluation.adriangb commentedon Dec 21, 2025
Hmm that's interesting, and I don't see any reason not to do it, but I don't think that's necessary,
PruningPredicateshould be able to handle that case.pepijnve commentedon Dec 22, 2025
I'll make a separate PR for that. I had a quick look at
PruningPredicateand it indeed already has more or less the same code to simplifyand trueandor false. I think it's a cleaner separation of concerns if the simplifier takes care of this instead.In other words, in
PruningPredicate::try_newperhaps it would be preferable to always run the incoming expression through the simplifier so thatbuild_predicate_expressiononly has to deal with the simplified versions instead of implementing ad hoc inline simplification.shashidhar-bm commentedon Dec 22, 2025
i tried the exact same thing in #19434, can you please check it once?
pepijnve commentedon Dec 22, 2025
@ShashidharM0118 that looks ok to me. I'm just sharing thoughts from the sidelines though. @adriangb is probably best placed to check.
The PR I had in mind would be complementary to this: pepijnve@2e160e0