This is an automated email from the ASF dual-hosted git repository.

mihaibudiu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/main by this push:
     new ee15085f4d [CALCITE-7854] FilterProjectTransposeRule throws 
NullPointerException when the Filter has a distribution trait and the Project's 
input has none
ee15085f4d is described below

commit ee15085f4d1c848434524d56ed2c2e4e22c53884
Author: Vladimir Sitnikov <[email protected]>
AuthorDate: Wed Oct 14 11:16:04 2026 +0300

    [CALCITE-7854] FilterProjectTransposeRule throws NullPointerException when 
the Filter has a distribution trait and the Project's input has none
    
    With copyFilter enabled, FilterProjectTransposeRule copied the input's
    distribution into the pushed Filter by wrapping
    input.getTraitSet().getTrait(RelDistributionTraitDef.INSTANCE) in a
    singleton list for RelTraitSet#replaceIfs. When the Filter's trait set
    has a distribution trait and the input's trait set has none, getTrait
    returns null, and RelCompositeTrait.of fails on the null element:
    
        java.lang.NullPointerException: Cannot invoke 
"org.apache.calcite.plan.RelTrait.getClass()" because "trait" is null
    
    This happens, for example, when the planner does not register
    RelDistributionTraitDef and a distribution is added to the Filter with
    RelTraitSet#plus.
    
    Use RelTraitSet#replaceIf, which takes a single trait and falls back to
    the trait's default when the supplier returns null; LogicalFilter.create
    sets the distribution the same way. The pushed Filter then has
    RelDistributions.ANY, the same fallback the rule already uses for
    collation. When the input has a distribution, the pushed Filter still
    takes it.
    
    Found while reviewing CALCITE-7736 (PR #5213), which carries the same
    fix inside its nullness migration.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../rel/rules/FilterProjectTransposeRule.java      |  6 +-
 .../org/apache/calcite/test/RelOptRulesTest.java   | 64 ++++++++++++++++++++++
 2 files changed, 66 insertions(+), 4 deletions(-)

diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/FilterProjectTransposeRule.java
 
b/core/src/main/java/org/apache/calcite/rel/rules/FilterProjectTransposeRule.java
index 3c365222cd..7851460550 100644
--- 
a/core/src/main/java/org/apache/calcite/rel/rules/FilterProjectTransposeRule.java
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/FilterProjectTransposeRule.java
@@ -35,7 +35,6 @@
 
 import org.immutables.value.Value;
 
-import java.util.Collections;
 import java.util.List;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
@@ -185,9 +184,8 @@ protected FilterProjectTransposeRule(
       final RelTraitSet traitSet = filter.getTraitSet()
           .replaceIfs(RelCollationTraitDef.INSTANCE,
               () -> 
input.getTraitSet().getTraits(RelCollationTraitDef.INSTANCE))
-          .replaceIfs(RelDistributionTraitDef.INSTANCE,
-              () -> Collections.singletonList(
-                  
input.getTraitSet().getTrait(RelDistributionTraitDef.INSTANCE)));
+          .replaceIf(RelDistributionTraitDef.INSTANCE,
+              () -> 
input.getTraitSet().getTrait(RelDistributionTraitDef.INSTANCE));
       newCondition = 
RexUtil.removeNullabilityCast(relBuilder.getTypeFactory(), newCondition);
       newFilterRel = filter.copy(traitSet, input, newCondition);
     } else {
diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java 
b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
index 7ca0dc464b..e68f5100fd 100644
--- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
@@ -42,6 +42,7 @@
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.RelCollationTraitDef;
 import org.apache.calcite.rel.RelCollations;
+import org.apache.calcite.rel.RelDistribution;
 import org.apache.calcite.rel.RelDistributionTraitDef;
 import org.apache.calcite.rel.RelDistributions;
 import org.apache.calcite.rel.RelFieldCollation;
@@ -66,6 +67,7 @@
 import org.apache.calcite.rel.logical.LogicalMinus;
 import org.apache.calcite.rel.logical.LogicalProject;
 import org.apache.calcite.rel.logical.LogicalTableModify;
+import org.apache.calcite.rel.logical.LogicalTableScan;
 import org.apache.calcite.rel.logical.LogicalUnion;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
 import org.apache.calcite.rel.rules.AggregateExpandWithinDistinctRule;
@@ -166,6 +168,7 @@
 
 import static org.apache.calcite.test.SqlToRelTestBase.NL;
 
+import static org.hamcrest.CoreMatchers.instanceOf;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.sameInstance;
 import static org.hamcrest.MatcherAssert.assertThat;
@@ -687,6 +690,67 @@ private HepProgram createHypergraphProgram() {
     }
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7854";>[CALCITE-7854]
+   * FilterProjectTransposeRule throws NullPointerException when the Filter has
+   * a distribution trait and the Project's input has none</a>.
+   *
+   * <p>The pushed Filter gets the default distribution. The rule used to put
+   * a null distribution into the new trait set and failed with
+   * NullPointerException. */
+  @Test void testFilterProjectTransposeRuleInputWithoutDistribution() {
+    assertThat(pushedFilterDistribution(null), is(RelDistributions.ANY));
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7854";>[CALCITE-7854]
+   * FilterProjectTransposeRule throws NullPointerException when the Filter has
+   * a distribution trait and the Project's input has none</a>.
+   *
+   * <p>The pushed Filter takes the input's distribution, not the one the
+   * original Filter had. */
+  @Test void testFilterProjectTransposeRuleInputWithDistribution() {
+    assertThat(pushedFilterDistribution(RelDistributions.SINGLETON),
+        is(RelDistributions.SINGLETON));
+  }
+
+  /** Applies {@link CoreRules#FILTER_PROJECT_TRANSPOSE} to
+   * Filter(Project(Scan EMP)) and returns the distribution of the pushed
+   * Filter.
+   *
+   * <p>The planner behind {@link RelBuilderTest#config()} does not register
+   * {@link RelDistributionTraitDef}, so distributions are added through
+   * {@link RelTraitSet#plus}: the Filter has {@code hash[0]}, and the scan has
+   * {@code inputDistribution}, or no distribution trait when it is null. */
+  private static @Nullable RelDistribution pushedFilterDistribution(
+      @Nullable RelDistribution inputDistribution) {
+    final RelBuilder b = RelBuilder.create(RelBuilderTest.config().build());
+    final TableScan scan = (TableScan) b.scan("EMP").build();
+    final RelNode input = inputDistribution == null
+        ? scan
+        : new LogicalTableScan(scan.getCluster(),
+            scan.getTraitSet().plus(inputDistribution), scan.getHints(),
+            scan.getTable());
+    final Filter filter = (Filter) b.push(input)
+        .project(b.field("SAL"))
+        .filter(b.equals(b.field(0), b.literal(11500)))
+        .build();
+    final RelNode in =
+        filter.copy(
+            
filter.getTraitSet().plus(RelDistributions.hash(ImmutableList.of(0))),
+            filter.getInput(), filter.getCondition());
+    final HepProgram program = new HepProgramBuilder()
+        .addRuleInstance(CoreRules.FILTER_PROJECT_TRANSPOSE)
+        .build();
+    final HepPlanner hepPlanner = new HepPlanner(program);
+    hepPlanner.setRoot(in);
+    final RelNode result = hepPlanner.findBestExp();
+
+    final RelNode pushedFilter = result.getInput(0);
+    assertThat(pushedFilter, instanceOf(Filter.class));
+    return 
pushedFilter.getTraitSet().getTrait(RelDistributionTraitDef.INSTANCE);
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6873";>[CALCITE-6873]
    * FilterProjectTransposeRule should not push the Filter past the Project

Reply via email to