From 49c02ec7c4b423c819d3be08f541630d21e64b52 Mon Sep 17 00:00:00 2001 From: Yifan Chen <30335308+emecii@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:01:28 -0700 Subject: [PATCH 1/3] fix: preserve short-circuit evaluation in Boolean CASE simplification Generated-by: OpenAI Codex --- .../simplify_expressions/expr_simplifier.rs | 173 +++++++++++------- datafusion/sqllogictest/test_files/case.slt | 58 ++++++ .../test_files/null_aware_mark_join.slt | 70 +++---- .../sqllogictest/test_files/subquery.slt | 34 ++-- 4 files changed, 206 insertions(+), 129 deletions(-) diff --git a/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs b/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs index 5436bd092163e..90d5db3821efe 100644 --- a/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs +++ b/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs @@ -1575,7 +1575,9 @@ impl TreeNodeRewriter for Simplifier<'_> { // ---> (X AND A) OR (Y AND B AND NOT X) OR ... (NOT (X OR Y) AND Q) // // Note: the rationale for this rewrite is that the expr can then be further - // simplified using the existing rules for AND/OR + // simplified using the existing rules for AND/OR. Unlike CASE, AND/OR + // do not guarantee branch-local evaluation, so only expose columns + // and literals from conditional branches. Expr::Case(Case { expr: None, when_then_expr, @@ -1586,7 +1588,8 @@ impl TreeNodeRewriter for Simplifier<'_> { // or all thens are literal bools and a small number of them are true || (when_then_expr.iter().all(|(_, then)| is_bool_lit(then)) && when_then_expr.iter().filter(|(_, then)| is_true(then)).count() < 3)) - && info.is_boolean_type(&when_then_expr[0].1)? => + && info.is_boolean_type(&when_then_expr[0].1)? + && can_lower_case_to_boolean(&when_then_expr, else_expr.as_deref()) => { // String disjunction of all the when predicates encountered so far. Not nullable. let mut filter_expr = lit(false); @@ -1643,7 +1646,8 @@ impl TreeNodeRewriter for Simplifier<'_> { .filter(|(_, then)| is_false(then)) .count() < 3 - && else_expr.as_deref().is_none_or(is_bool_lit) => + && else_expr.as_deref().is_none_or(is_bool_lit) + && can_lower_case_to_boolean(&when_then_expr, else_expr.as_deref()) => { Transformed::yes( Expr::Case(Case { @@ -2432,6 +2436,26 @@ fn simplify_inlist_set_operation( })) } +/// Conservatively checks the inputs whose evaluation can change when lowering +/// CASE to AND/OR. [`Expr`] has no general fallibility analysis: only columns and +/// literals are admitted from conditional branches, including later WHEN conditions. +/// The first WHEN already runs on every row, but must not be volatile because +/// the rewrite can evaluate it more than once. +fn can_lower_case_to_boolean( + when_then_expr: &[(Box, Box)], + else_expr: Option<&Expr>, +) -> bool { + let is_leaf = |expr: &Expr| matches!(expr, Expr::Column(_) | Expr::Literal(..)); + when_then_expr.iter().enumerate().all(|(i, (when, then))| { + is_leaf(then) + && if i == 0 { + !when.is_volatile() + } else { + is_leaf(when) + } + }) && else_expr.is_none_or(is_leaf) +} + /// Returns expression testing a boolean `expr` for being exactly `true` (not `false` or NULL). fn is_exactly_true(expr: Expr, info: &SimplifyContext) -> Result { if !info.nullable(&expr)? { @@ -2577,12 +2601,15 @@ mod tests { // ELSE false // END // - // Can be simplified to `i < 5` + // Fold the constant conditions, but preserve CASE because the THEN + // expression is outside the conservative column/literal subset. let expr = when(col("i").gt(lit(5)).and(lit(false)), col("i").gt(lit(5))) .when(col("i").lt(lit(5)).and(lit(true)), col("i").lt(lit(5))) .otherwise(lit(false)) .unwrap(); - let expected = col("i").lt(lit(5)); + let expected = when(col("i").lt(lit(5)), col("i").lt(lit(5))) + .otherwise(lit(false)) + .unwrap(); assert_eq!(expected, simplifier.simplify(expr).unwrap()); } @@ -4210,76 +4237,84 @@ mod tests { Some(Box::new(lit("ready"))), )); - assert_eq!( - simplify(binary_expr( - complex_case.clone(), + // Comparisons still fold into literal outputs, but later WHEN conditions must + // remain conditional: they are not columns or literals. + for (op, value, outputs) in [ + ( Operator::Eq, - lit("completed"), - )), - not_distinct_from(col("c1").eq(lit("completed")), lit(true)).and( - distinct_from(col("c1").eq(lit("inboxed")), lit(true)) - .and(distinct_from(col("c1").eq(lit("scheduled")), lit(true))) - ) - ); - - assert_eq!( - simplify(binary_expr( - complex_case.clone(), + "completed", + [false, false, true, false, false, false, false], + ), + ( Operator::NotEq, - lit("completed"), - )), - distinct_from(col("c1").eq(lit("completed")), lit(true)) - .or(not_distinct_from(col("c1").eq(lit("inboxed")), lit(true)) - .or(not_distinct_from(col("c1").eq(lit("scheduled")), lit(true)))) - ); - - assert_eq!( - simplify(binary_expr( - complex_case.clone(), + "completed", + [true, true, false, true, true, true, true], + ), + ( Operator::Eq, - lit("running"), - )), - not_distinct_from(col("c2"), lit(true)).and( - distinct_from(col("c1").eq(lit("inboxed")), lit(true)) - .and(distinct_from(col("c1").eq(lit("scheduled")), lit(true))) - .and(distinct_from(col("c1").eq(lit("completed")), lit(true))) - .and(distinct_from(col("c1").eq(lit("paused")), lit(true))) - ) - ); - - assert_eq!( - simplify(binary_expr( - complex_case.clone(), + "running", + [false, false, false, false, true, false, false], + ), + ( Operator::Eq, - lit("ready"), - )), - distinct_from(col("c1").eq(lit("inboxed")), lit(true)) - .and(distinct_from(col("c1").eq(lit("scheduled")), lit(true))) - .and(distinct_from(col("c1").eq(lit("completed")), lit(true))) - .and(distinct_from(col("c1").eq(lit("paused")), lit(true))) - .and(distinct_from(col("c2"), lit(true))) - .and(distinct_from( - col("c1").eq(lit("invoked")).and(col("c3").gt(lit(0))), - lit(true) - )) - ); - - assert_eq!( - simplify(binary_expr( - complex_case.clone(), + "ready", + [false, false, false, false, false, false, true], + ), + ( Operator::NotEq, - lit("ready"), + "ready", + [true, true, true, true, true, true, false], + ), + ] { + let Expr::Case(mut expected) = complex_case.clone() else { + unreachable!() + }; + for ((_, then), value) in expected.when_then_expr.iter_mut().zip(outputs) { + **then = lit(value); + } + expected.else_expr = Some(Box::new(lit(outputs[6]))); + assert_eq!( + simplify(binary_expr(complex_case.clone(), op, lit(value))), + Expr::Case(expected) + ); + } + } + + #[test] + fn simplify_case_preserves_conditional_expressions() { + let fallible = + Expr::Cast(Cast::new(Box::new(col("c1")), DataType::Int32)).gt(lit(0_i32)); + let volatile = Expr::ScalarFunction(ScalarFunction::new_udf( + Arc::new(ScalarUDF::new_from_impl(VolatileUdf::new())), + vec![], + )); + for expr in [ + Expr::Case(Case::new( + None, + vec![(Box::new(col("c2")), Box::new(fallible.clone()))], + Some(Box::new(lit(false))), )), - not_distinct_from(col("c1").eq(lit("inboxed")), lit(true)) - .or(not_distinct_from(col("c1").eq(lit("scheduled")), lit(true))) - .or(not_distinct_from(col("c1").eq(lit("completed")), lit(true))) - .or(not_distinct_from(col("c1").eq(lit("paused")), lit(true))) - .or(not_distinct_from(col("c2"), lit(true))) - .or(not_distinct_from( - col("c1").eq(lit("invoked")).and(col("c3").gt(lit(0))), - lit(true) - )) - ); + Expr::Case(Case::new( + None, + vec![(Box::new(col("c2")), Box::new(lit(true)))], + Some(Box::new(fallible.clone())), + )), + Expr::Case(Case::new( + None, + vec![ + (Box::new(col("c2")), Box::new(lit(false))), + (Box::new(fallible), Box::new(lit(true))), + ], + Some(Box::new(lit(false))), + )), + Expr::Case(Case::new( + None, + vec![(Box::new(volatile.gt(lit(0_i16))), Box::new(lit(true)))], + Some(Box::new(col("c2"))), + )), + ] { + assert_eq!(simplify(expr.clone()), expr); + } } #[test] diff --git a/datafusion/sqllogictest/test_files/case.slt b/datafusion/sqllogictest/test_files/case.slt index f7ae380242942..8e067363318e0 100644 --- a/datafusion/sqllogictest/test_files/case.slt +++ b/datafusion/sqllogictest/test_files/case.slt @@ -906,3 +906,61 @@ drop table floats; ##### # End of lookup table CASE tests ##### + +statement ok +CREATE VIEW guarded_cast AS SELECT * FROM (VALUES ('1'), ('abc'), ('2'), (NULL)) t(s); + +# A Boolean CASE must not become an AND that exposes the cast to 'abc'. +# Regression for https://github.com/apache/datafusion/issues/25136. +query T rowsort +SELECT s FROM guarded_cast +WHERE CASE WHEN s ~ '^[0-9]+$' THEN CAST(s AS INT) > 0 ELSE false END; +---- +1 +2 + +# Preserve branch evaluation in projections, including both forms of ELSE NULL. +query TBBB rowsort +SELECT s, + CASE WHEN s ~ '^[0-9]+$' THEN CAST(s AS INT) > 0 ELSE true END, + CASE WHEN s ~ '^[0-9]+$' THEN CAST(s AS INT) > 0 ELSE NULL END, + CASE WHEN s ~ '^[0-9]+$' THEN CAST(s AS INT) > 0 END +FROM guarded_cast; +---- +1 true true true +2 true true true +NULL true NULL NULL +abc true NULL NULL + +# The ELSE expression also runs only on rows that reach it. +query T rowsort +SELECT s FROM guarded_cast +WHERE CASE WHEN s ~ '^[a-z]+$' THEN false ELSE CAST(s AS INT) > 0 END; +---- +1 +2 + +# Literal outputs alone are insufficient: later WHEN expressions are conditional. +query T rowsort +SELECT s FROM guarded_cast +WHERE CASE WHEN s ~ '^[a-z]+$' THEN false + WHEN CAST(s AS INT) > 0 THEN true ELSE false END; +---- +1 +2 + +# Division is another fallible branch expression, independent of cast handling. +query I rowsort +SELECT n FROM (VALUES (0), (1), (2), (NULL)) t(n) +WHERE CASE WHEN n <> 0 THEN 10 / n > 1 ELSE false END; +---- +1 +2 + +# A selected invalid branch must still raise its original error. +query error Cannot cast string 'abc' to value of Int32 type +SELECT CASE WHEN s ~ '^[a-z]+$' THEN CAST(s AS INT) > 0 ELSE false END +FROM guarded_cast; + +statement ok +DROP VIEW guarded_cast; diff --git a/datafusion/sqllogictest/test_files/null_aware_mark_join.slt b/datafusion/sqllogictest/test_files/null_aware_mark_join.slt index 62c0dd3192a29..9d606bbe9bceb 100644 --- a/datafusion/sqllogictest/test_files/null_aware_mark_join.slt +++ b/datafusion/sqllogictest/test_files/null_aware_mark_join.slt @@ -298,28 +298,23 @@ WHERE (id = ANY (SELECT id FROM inner_table_with_null)) IS NULL; ---- logical_plan 01)Projection: outer_table.value -02)--Filter: __correlated_sq_1.mark OR __correlated_sq_2.mark AND NOT __correlated_sq_3.mark AND Boolean(NULL) IS NULL -03)----Projection: outer_table.value, __correlated_sq_1.mark, __correlated_sq_2.mark, __correlated_sq_3.mark -04)------LeftMark Join: Filter: outer_table.id = __correlated_sq_3.id IS TRUE -05)--------LeftMark Join: Filter: outer_table.id = __correlated_sq_2.id IS NULL -06)----------LeftMark Join: Filter: outer_table.id = __correlated_sq_1.id IS TRUE -07)------------TableScan: outer_table projection=[id, value] -08)------------SubqueryAlias: __correlated_sq_1 -09)--------------TableScan: inner_table_with_null projection=[id] -10)----------SubqueryAlias: __correlated_sq_2 -11)------------TableScan: inner_table_with_null projection=[id] -12)--------SubqueryAlias: __correlated_sq_3 -13)----------TableScan: inner_table_with_null projection=[id] +02)--Filter: __correlated_sq_1.mark IS NOT DISTINCT FROM Boolean(true) OR __correlated_sq_2.mark IS NOT DISTINCT FROM Boolean(true) AND __correlated_sq_1.mark IS DISTINCT FROM Boolean(true) AND Boolean(NULL) IS NULL +03)----Projection: outer_table.value, __correlated_sq_1.mark, __correlated_sq_2.mark +04)------LeftMark Join: Filter: outer_table.id = __correlated_sq_2.id IS NULL +05)--------LeftMark Join: Filter: outer_table.id = __correlated_sq_1.id IS TRUE +06)----------TableScan: outer_table projection=[id, value] +07)----------SubqueryAlias: __correlated_sq_1 +08)------------TableScan: inner_table_with_null projection=[id] +09)--------SubqueryAlias: __correlated_sq_2 +10)----------TableScan: inner_table_with_null projection=[id] physical_plan -01)FilterExec: mark@1 OR mark@2 AND NOT mark@3 AND NULL IS NULL, projection=[value@0] -02)--NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true, projection=[value@1, mark@2, mark@3, mark@4] +01)FilterExec: mark@1 IS NOT DISTINCT FROM true OR mark@2 IS NOT DISTINCT FROM true AND mark@1 IS DISTINCT FROM true AND NULL IS NULL, projection=[value@0] +02)--NestedLoopJoinExec: join_type=RightMark, filter=id@0 = id@1 IS NULL, projection=[value@1, mark@2, mark@3] 03)----DataSourceExec: partitions=1, partition_sizes=[1] -04)----NestedLoopJoinExec: join_type=RightMark, filter=id@0 = id@1 IS NULL -05)------DataSourceExec: partitions=1, partition_sizes=[1] -06)------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 -07)--------NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true -08)----------DataSourceExec: partitions=1, partition_sizes=[1] -09)----------DataSourceExec: partitions=1, partition_sizes=[1] +04)----RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 +05)------NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true +06)--------DataSourceExec: partitions=1, partition_sizes=[1] +07)--------DataSourceExec: partitions=1, partition_sizes=[1] query T rowsort SELECT value @@ -338,28 +333,23 @@ WHERE (id = ANY (SELECT id FROM inner_table_no_null)) IS TRUE; ---- logical_plan 01)Projection: outer_table.value -02)--Filter: __correlated_sq_1.mark OR __correlated_sq_2.mark AND NOT __correlated_sq_3.mark AND Boolean(NULL) IS TRUE -03)----Projection: outer_table.value, __correlated_sq_1.mark, __correlated_sq_2.mark, __correlated_sq_3.mark -04)------LeftMark Join: Filter: outer_table.id = __correlated_sq_3.id IS TRUE -05)--------LeftMark Join: Filter: outer_table.id = __correlated_sq_2.id IS NULL -06)----------LeftMark Join: Filter: outer_table.id = __correlated_sq_1.id IS TRUE -07)------------TableScan: outer_table projection=[id, value] -08)------------SubqueryAlias: __correlated_sq_1 -09)--------------TableScan: inner_table_no_null projection=[id] -10)----------SubqueryAlias: __correlated_sq_2 -11)------------TableScan: inner_table_no_null projection=[id] -12)--------SubqueryAlias: __correlated_sq_3 -13)----------TableScan: inner_table_no_null projection=[id] +02)--Filter: __correlated_sq_1.mark IS NOT DISTINCT FROM Boolean(true) OR __correlated_sq_2.mark IS NOT DISTINCT FROM Boolean(true) AND __correlated_sq_1.mark IS DISTINCT FROM Boolean(true) AND Boolean(NULL) IS TRUE +03)----Projection: outer_table.value, __correlated_sq_1.mark, __correlated_sq_2.mark +04)------LeftMark Join: Filter: outer_table.id = __correlated_sq_2.id IS NULL +05)--------LeftMark Join: Filter: outer_table.id = __correlated_sq_1.id IS TRUE +06)----------TableScan: outer_table projection=[id, value] +07)----------SubqueryAlias: __correlated_sq_1 +08)------------TableScan: inner_table_no_null projection=[id] +09)--------SubqueryAlias: __correlated_sq_2 +10)----------TableScan: inner_table_no_null projection=[id] physical_plan -01)FilterExec: (mark@1 OR mark@2 AND NOT mark@3 AND NULL) IS NOT DISTINCT FROM true, projection=[value@0] -02)--NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true, projection=[value@1, mark@2, mark@3, mark@4] +01)FilterExec: (mark@1 IS NOT DISTINCT FROM true OR mark@2 IS NOT DISTINCT FROM true AND mark@1 IS DISTINCT FROM true AND NULL) IS NOT DISTINCT FROM true, projection=[value@0] +02)--NestedLoopJoinExec: join_type=RightMark, filter=id@0 = id@1 IS NULL, projection=[value@1, mark@2, mark@3] 03)----DataSourceExec: partitions=1, partition_sizes=[1] -04)----NestedLoopJoinExec: join_type=RightMark, filter=id@0 = id@1 IS NULL -05)------DataSourceExec: partitions=1, partition_sizes=[1] -06)------RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 -07)--------NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true -08)----------DataSourceExec: partitions=1, partition_sizes=[1] -09)----------DataSourceExec: partitions=1, partition_sizes=[1] +04)----RepartitionExec: partitioning=RoundRobinBatch(2), input_partitions=1 +05)------NestedLoopJoinExec: join_type=RightMark, filter=(id@0 = id@1) IS NOT DISTINCT FROM true +06)--------DataSourceExec: partitions=1, partition_sizes=[1] +07)--------DataSourceExec: partitions=1, partition_sizes=[1] query T rowsort SELECT value diff --git a/datafusion/sqllogictest/test_files/subquery.slt b/datafusion/sqllogictest/test_files/subquery.slt index 2da7483658853..5188bb00e8e88 100644 --- a/datafusion/sqllogictest/test_files/subquery.slt +++ b/datafusion/sqllogictest/test_files/subquery.slt @@ -1781,27 +1781,21 @@ explain select v from (values (1), (6), (10)) set_cmp_t(v) where v > any(select ---- logical_plan 01)Projection: set_cmp_t.v -02)--LeftMark Join: Filter: set_cmp_t.v > __correlated_sq_3.v IS TRUE +02)--LeftMark Join: Filter: set_cmp_t.v > __correlated_sq_2.v IS NULL 03)----Projection: set_cmp_t.v -04)------LeftMark Join: Filter: set_cmp_t.v > __correlated_sq_2.v IS NULL -05)--------Projection: set_cmp_t.v -06)----------Filter: __correlated_sq_1.mark -07)------------LeftMark Join: Filter: set_cmp_t.v > __correlated_sq_1.v IS TRUE -08)--------------SubqueryAlias: set_cmp_t -09)----------------Projection: column1 AS v -10)------------------Values: (Int64(1)), (Int64(6)), (Int64(10)) -11)--------------SubqueryAlias: __correlated_sq_1 -12)----------------SubqueryAlias: set_cmp_s -13)------------------Projection: column1 AS v -14)--------------------Values: (Int64(5)), (Int64(NULL)) -15)--------SubqueryAlias: __correlated_sq_2 -16)----------SubqueryAlias: set_cmp_s -17)------------Projection: column1 AS v -18)--------------Values: (Int64(5)), (Int64(NULL)) -19)----SubqueryAlias: __correlated_sq_3 -20)------SubqueryAlias: set_cmp_s -21)--------Projection: column1 AS v -22)----------Values: (Int64(5)), (Int64(NULL)) +04)------Filter: __correlated_sq_1.mark IS NOT DISTINCT FROM Boolean(true) +05)--------LeftMark Join: Filter: set_cmp_t.v > __correlated_sq_1.v IS TRUE +06)----------SubqueryAlias: set_cmp_t +07)------------Projection: column1 AS v +08)--------------Values: (Int64(1)), (Int64(6)), (Int64(10)) +09)----------SubqueryAlias: __correlated_sq_1 +10)------------SubqueryAlias: set_cmp_s +11)--------------Projection: column1 AS v +12)----------------Values: (Int64(5)), (Int64(NULL)) +13)----SubqueryAlias: __correlated_sq_2 +14)------SubqueryAlias: set_cmp_s +15)--------Projection: column1 AS v +16)----------Values: (Int64(5)), (Int64(NULL)) # same-table `= ANY` / `<> ALL` must plan without # "duplicate unqualified field name mark". From 3d52a60d00a079b60720986286eda226f9a16600 Mon Sep 17 00:00:00 2001 From: Yifan Chen <30335308+emecii@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:07:54 -0700 Subject: [PATCH 2/3] fix: preserve first CASE condition evaluation --- .../simplify_expressions/expr_simplifier.rs | 78 +++++++------------ datafusion/sqllogictest/test_files/case.slt | 5 ++ 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs b/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs index 90d5db3821efe..f25c9fed2e514 100644 --- a/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs +++ b/datafusion/optimizer/src/simplify_expressions/expr_simplifier.rs @@ -2438,22 +2438,16 @@ fn simplify_inlist_set_operation( /// Conservatively checks the inputs whose evaluation can change when lowering /// CASE to AND/OR. [`Expr`] has no general fallibility analysis: only columns and -/// literals are admitted from conditional branches, including later WHEN conditions. -/// The first WHEN already runs on every row, but must not be volatile because -/// the rewrite can evaluate it more than once. +/// literals are admitted for all WHEN conditions and branch outputs. fn can_lower_case_to_boolean( when_then_expr: &[(Box, Box)], else_expr: Option<&Expr>, ) -> bool { let is_leaf = |expr: &Expr| matches!(expr, Expr::Column(_) | Expr::Literal(..)); - when_then_expr.iter().enumerate().all(|(i, (when, then))| { - is_leaf(then) - && if i == 0 { - !when.is_volatile() - } else { - is_leaf(when) - } - }) && else_expr.is_none_or(is_leaf) + when_then_expr + .iter() + .all(|(when, then)| is_leaf(when) && is_leaf(then)) + && else_expr.is_none_or(is_leaf) } /// Returns expression testing a boolean `expr` for being exactly `true` (not `false` or NULL). @@ -4289,6 +4283,11 @@ mod tests { vec![], )); for expr in [ + Expr::Case(Case::new( + None, + vec![(Box::new(fallible.clone()), Box::new(lit(false)))], + Some(Box::new(lit(false))), + )), Expr::Case(Case::new( None, vec![(Box::new(col("c2")), Box::new(fallible.clone()))], @@ -4358,19 +4357,20 @@ mod tests { // CASE WHEN ISNULL(c2) THEN true ELSE c2 // --> - // ISNULL(c2) OR c2 - // - // Need to call simplify 2x due to - // https://github.com/apache/datafusion/issues/1160 + // Preserve CASE because the WHEN expression is outside the conservative + // column/literal subset. + let expected = Expr::Case(Case::new( + None, + vec![(Box::new(col("c2").is_null()), Box::new(lit(true)))], + Some(Box::new(col("c2"))), + )); assert_eq!( simplify(simplify(Expr::Case(Case::new( None, vec![(Box::new(col("c2").is_null()), Box::new(lit(true)),)], Some(Box::new(col("c2"))), )))), - col("c2") - .is_null() - .or(col("c2").is_not_null().and(col("c2"))) + expected ); // CASE WHEN c1 then true WHEN c2 then false ELSE true @@ -4411,29 +4411,21 @@ mod tests { col("c1_non_null").or(col("c1_non_null").not().and(col("c2_non_null").not())) ); - // CASE WHEN c > 0 THEN true END AS c1 - assert_eq!( - simplify(simplify(Expr::Case(Case::new( + // Preserve CASE with a non-leaf WHEN condition, with or without ELSE. + for expr in [ + Expr::Case(Case::new( None, vec![(Box::new(col("c3").gt(lit(0_i64))), Box::new(lit(true)))], None, - )))), - not_distinct_from(col("c3").gt(lit(0_i64)), lit(true)).or(distinct_from( - col("c3").gt(lit(0_i64)), - lit(true) - ) - .and(lit_bool_null())) - ); - - // CASE WHEN c > 0 THEN true ELSE false END AS c1 - assert_eq!( - simplify(simplify(Expr::Case(Case::new( + )), + Expr::Case(Case::new( None, vec![(Box::new(col("c3").gt(lit(0_i64))), Box::new(lit(true)))], Some(Box::new(lit(false))), - )))), - not_distinct_from(col("c3").gt(lit(0_i64)), lit(true)) - ); + )), + ] { + assert_eq!(simplify(simplify(expr.clone())), expr); + } } #[test] @@ -4630,22 +4622,6 @@ mod tests { assert_eq!(simplify(expr.clone()), expr); } - fn distinct_from(left: impl Into, right: impl Into) -> Expr { - Expr::BinaryExpr(BinaryExpr { - left: Box::new(left.into()), - op: Operator::IsDistinctFrom, - right: Box::new(right.into()), - }) - } - - fn not_distinct_from(left: impl Into, right: impl Into) -> Expr { - Expr::BinaryExpr(BinaryExpr { - left: Box::new(left.into()), - op: Operator::IsNotDistinctFrom, - right: Box::new(right.into()), - }) - } - #[test] fn simplify_expr_bool_or() { // col || true is always true diff --git a/datafusion/sqllogictest/test_files/case.slt b/datafusion/sqllogictest/test_files/case.slt index 8e067363318e0..b25041f6effa0 100644 --- a/datafusion/sqllogictest/test_files/case.slt +++ b/datafusion/sqllogictest/test_files/case.slt @@ -962,5 +962,10 @@ query error Cannot cast string 'abc' to value of Int32 type SELECT CASE WHEN s ~ '^[a-z]+$' THEN CAST(s AS INT) > 0 ELSE false END FROM guarded_cast; +# The first WHEN must be evaluated even when both branch outputs are identical. +query error Cannot cast string 'abc' to value of Int32 type +SELECT CASE WHEN CAST(s AS INT) > 0 THEN false ELSE false END +FROM (VALUES ('abc')) t(s); + statement ok DROP VIEW guarded_cast; From 2effd1d4b9a524e5b05b913ff472ea53067c9c05 Mon Sep 17 00:00:00 2001 From: Yifan Chen <30335308+emecii@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:32:02 -0700 Subject: [PATCH 3/3] test: preserve CASE in correlated aggregate plan expectation Keep the stricter first-WHEN guard reflected in the EXPLAIN snapshot. Cover non-NULL, all-NULL, empty, and NULL-key correlated aggregate results. Generated-by: OpenAI Codex --- datafusion/sqllogictest/test_files/subquery.slt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/datafusion/sqllogictest/test_files/subquery.slt b/datafusion/sqllogictest/test_files/subquery.slt index 5188bb00e8e88..cd557d4d722f0 100644 --- a/datafusion/sqllogictest/test_files/subquery.slt +++ b/datafusion/sqllogictest/test_files/subquery.slt @@ -1191,7 +1191,7 @@ query TT explain SELECT t1_id, (SELECT max(t2.t2_id) is null FROM t2 WHERE t2.t2_int = t1.t1_int) x from t1 ---- logical_plan -01)Projection: t1.t1_id, __scalar_sq_1.__always_true IS NULL OR __scalar_sq_1.__always_true IS NOT NULL AND __scalar_sq_1.max(t2.t2_id) IS NULL AS x +01)Projection: t1.t1_id, CASE WHEN __scalar_sq_1.__always_true IS NULL THEN Boolean(true) ELSE __scalar_sq_1.max(t2.t2_id) IS NULL END AS x 02)--Left Join: t1.t1_int = __scalar_sq_1.t2_int 03)----TableScan: t1 projection=[t1_id, t1_int] 04)----SubqueryAlias: __scalar_sq_1 @@ -1199,6 +1199,19 @@ logical_plan 06)--------Aggregate: groupBy=[[t2.t2_int]], aggr=[[max(t2.t2_id)]] 07)----------TableScan: t2 projection=[t2_id, t2_int] +# A correlated aggregate must distinguish a non-NULL maximum from both an +# all-NULL group and an empty group (including a NULL correlation key). +query IB rowsort +WITH outer_rows(k) AS (VALUES (1), (2), (3), (NULL)), + inner_rows(k, v) AS (VALUES (1, 10), (1, NULL), (2, NULL), (NULL, 20)) +SELECT k, (SELECT max(v) IS NULL FROM inner_rows WHERE inner_rows.k = outer_rows.k) +FROM outer_rows +---- +1 false +2 true +3 true +NULL true + query TT explain SELECT t1_id, (SELECT max(t2.t2_id) FROM t2 WHERE t2.t2_int = t1.t1_int) x from t1 ----