From 74fdc06b762e2cb3bb49ca76edcba54d8f2fc043 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Thu, 3 Sep 2026 19:31:17 +0000 Subject: [PATCH] Preserve NULL matches in extended CASE filters Remove the filter simplifier's assumption that a NULL CASE operand always selects ELSE. Extended CASE predicates can match NULL, so predicate-aware evaluation must decide which branch is selected. --- .../rule/SimplifyFilterPredicate.java | 10 ++-- .../rule/TestSimplifyFilterPredicate.java | 34 +++++++++---- .../io/trino/sql/query/TestExtendedCase.java | 51 +++++++++++++++++++ 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/core/trino-main/src/main/java/io/trino/sql/planner/iterative/rule/SimplifyFilterPredicate.java b/core/trino-main/src/main/java/io/trino/sql/planner/iterative/rule/SimplifyFilterPredicate.java index f58fd8332938..ede780add9c9 100644 --- a/core/trino-main/src/main/java/io/trino/sql/planner/iterative/rule/SimplifyFilterPredicate.java +++ b/core/trino-main/src/main/java/io/trino/sql/planner/iterative/rule/SimplifyFilterPredicate.java @@ -209,19 +209,15 @@ private Optional simplify(Session session, Case caseExpression) private static Optional simplify(Match caseExpression) { - Optional defaultValue = Optional.of(caseExpression.defaultValue()); - - if (caseExpression.operand() instanceof Constant literal && literal.value() == null) { - return defaultValue; - } + Expression defaultValue = caseExpression.defaultValue(); List results = caseExpression.clauses().stream() .map(MatchClause::result) .collect(toImmutableList()); - if (results.stream().allMatch(result -> result.equals(TRUE)) && defaultValue.get().equals(TRUE)) { + if (results.stream().allMatch(result -> result.equals(TRUE)) && defaultValue.equals(TRUE)) { return Optional.of(TRUE); } - if (results.stream().allMatch(SimplifyFilterPredicate::isNotTrue) && isNotTrue(defaultValue.get())) { + if (results.stream().allMatch(SimplifyFilterPredicate::isNotTrue) && isNotTrue(defaultValue)) { return Optional.of(FALSE); } return Optional.empty(); diff --git a/core/trino-main/src/test/java/io/trino/sql/planner/iterative/rule/TestSimplifyFilterPredicate.java b/core/trino-main/src/test/java/io/trino/sql/planner/iterative/rule/TestSimplifyFilterPredicate.java index 82772e7dbfeb..3cea0644283d 100644 --- a/core/trino-main/src/test/java/io/trino/sql/planner/iterative/rule/TestSimplifyFilterPredicate.java +++ b/core/trino-main/src/test/java/io/trino/sql/planner/iterative/rule/TestSimplifyFilterPredicate.java @@ -23,6 +23,7 @@ import io.trino.sql.ir.Expression; import io.trino.sql.ir.IrExpressions; import io.trino.sql.ir.IsNull; +import io.trino.sql.ir.Lambda; import io.trino.sql.ir.Logical; import io.trino.sql.ir.Match; import io.trino.sql.ir.MatchClause; @@ -42,6 +43,7 @@ import static io.trino.sql.ir.Booleans.TRUE; import static io.trino.sql.ir.ComparisonOperator.EQUAL; import static io.trino.sql.ir.ComparisonOperator.GREATER_THAN; +import static io.trino.sql.ir.ComparisonOperator.IDENTICAL; import static io.trino.sql.ir.ComparisonOperator.LESS_THAN; import static io.trino.sql.ir.IrExpressions.ifExpression; import static io.trino.sql.ir.Logical.Operator.AND; @@ -402,6 +404,25 @@ public void testSimplifySearchedCaseExpression() .doesNotFire(); } + @Test + public void testNullOperandCanMatch() + { + Symbol operand = new Symbol(INTEGER, "operand"); + Lambda predicate = new Lambda(ImmutableList.of(operand), comparison(IDENTICAL, operand.toSymbolReference(), new Reference(INTEGER, "a"))); + + tester().assertThat(new SimplifyFilterPredicate(FUNCTIONS.getMetadata())) + .on(p -> p.filter( + new Match(new Constant(INTEGER, null), ImmutableList.of(new MatchClause(predicate, TRUE)), FALSE), + p.values(p.symbol("a", INTEGER)))) + .doesNotFire(); + + tester().assertThat(new SimplifyFilterPredicate(FUNCTIONS.getMetadata())) + .on(p -> p.filter( + new Match(new Constant(INTEGER, null), ImmutableList.of(new MatchClause(predicate, FALSE)), TRUE), + p.values(p.symbol("a", INTEGER)))) + .doesNotFire(); + } + @Test public void testSimplifySimpleCaseExpression() { @@ -416,7 +437,7 @@ public void testSimplifySimpleCaseExpression() p.values(p.symbol("a"), p.symbol("b")))) .doesNotFire(); - // comparison with null returns null - no WHEN branch matches, return default value + // Null operands are evaluated by the IR optimizer using the clause predicates. tester().assertThat(new SimplifyFilterPredicate(FUNCTIONS.getMetadata())) .on(p -> p.filter( new Match( @@ -426,12 +447,8 @@ public void testSimplifySimpleCaseExpression() equalityClause(new Reference(BOOLEAN, "a"), FALSE)), new Reference(BOOLEAN, "b")), p.values(p.symbol("a"), p.symbol("b")))) - .matches( - filter( - new Reference(BOOLEAN, "b"), - values("a", "b"))); + .doesNotFire(); - // comparison with null returns null - no WHEN branch matches, the result is default null, simplified to FALSE tester().assertThat(new SimplifyFilterPredicate(FUNCTIONS.getMetadata())) .on(p -> p.filter( new Match( @@ -441,10 +458,7 @@ public void testSimplifySimpleCaseExpression() equalityClause(new Reference(BOOLEAN, "a"), FALSE)), NULL_BOOLEAN), p.values(p.symbol("a")))) - .matches( - filter( - FALSE, - values("a"))); + .doesNotFire(); // all results true tester().assertThat(new SimplifyFilterPredicate(FUNCTIONS.getMetadata())) diff --git a/core/trino-main/src/test/java/io/trino/sql/query/TestExtendedCase.java b/core/trino-main/src/test/java/io/trino/sql/query/TestExtendedCase.java index a633948d2923..3bb93823d170 100644 --- a/core/trino-main/src/test/java/io/trino/sql/query/TestExtendedCase.java +++ b/core/trino-main/src/test/java/io/trino/sql/query/TestExtendedCase.java @@ -220,6 +220,57 @@ WHEN IS NOT DISTINCT FROM CAST(NULL AS integer) THEN 1 .matches("VALUES 1, 2, 1"); } + @Test + public void testNullOperandInFilter() + { + assertThat(assertions.query( + """ + SELECT x + FROM UNNEST(ARRAY[NULL, 1]) t(x) + WHERE CASE CAST(NULL AS integer) WHEN IS NOT DISTINCT FROM x THEN true ELSE false END + """)) + .matches("VALUES CAST(NULL AS integer)"); + } + + @Test + public void testNullOperandInFilterWithTrueDefault() + { + assertThat(assertions.query( + """ + SELECT x + FROM UNNEST(ARRAY[NULL, 1]) t(x) + WHERE CASE CAST(NULL AS integer) WHEN IS NOT DISTINCT FROM x THEN false ELSE true END + """)) + .matches("VALUES 1"); + } + + @Test + public void testNullOperandControls() + { + assertThat(assertions.query( + """ + SELECT x, CASE CAST(NULL AS integer) WHEN IS NOT DISTINCT FROM x THEN true ELSE false END + FROM UNNEST(ARRAY[NULL, 1]) t(x) + """)) + .matches("VALUES (CAST(NULL AS integer), true), (1, false)"); + + assertThat(assertions.query( + """ + SELECT x + FROM UNNEST(ARRAY[NULL, 1]) t(x) + WHERE CASE WHEN CAST(NULL AS integer) IS NOT DISTINCT FROM x THEN true ELSE false END + """)) + .matches("VALUES CAST(NULL AS integer)"); + + assertThat(assertions.query( + """ + SELECT x + FROM UNNEST(ARRAY[NULL, 1]) t(x) + WHERE CASE CAST(NULL AS integer) WHEN x THEN false ELSE true END + """)) + .matches("VALUES CAST(NULL AS integer), 1"); + } + @Test public void testTypeReconciliation() {