Repository: apache/calcite
Pull request #4366: [CALCITE-7002] Create an optimization rule to eliminate UNION from the same source with different filters
Author: login: xiedeyantu
Created: 2025-05-09T10:36:28Z | Merged: 2025-05-13T20:54:44Z

Jira link: [CALCITE-7002](https://issues.apache.org/jira/browse/CALCITE-7002)


Diff:
diff --git a/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java b/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
index 94b69febcc76..89753edb1db0 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
@@ -388,7 +388,21 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule that translates a {@link Union} to {@link Filter}. */
+  public static final SetOpToFilterRule UNION_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.UNION.toRule();
+
+  /** Rule that translates a {@link Intersect} to {@link Filter}. */
+  public static final SetOpToFilterRule INTERSECT_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.INTERSECT.toRule();
+
   /** Rule that translates a {@link Minus} to {@link Filter}. */
+  public static final SetOpToFilterRule MINUS_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.MINUS.toRule();
+
+  /** Rule that sames as {@link CoreRules#MINUS_FILTER_TO_FILTER},
+   * But it is deprecated. */
+  @Deprecated
   public static final MinusToFilterRule MINUS_TO_FILTER =
       MinusToFilterRule.Config.DEFAULT.toRule();
 
diff --git a/core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java b/core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
index 2a04991b0bac..0753e1cecaf0 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
@@ -51,7 +51,10 @@ public class MinusToFilterRule
     extends RelRule<MinusToFilterRule.Config>
     implements TransformationRule {
 
-  /** Creates an MinusToFilterRule. */
+  /**
+   * This rule is replaced by {@link SetOpToFilterRule}.
+   * Please see {@link CoreRules#MINUS_FILTER_TO_FILTER} */
+  @Deprecated
   protected MinusToFilterRule(Config config) {
     super(config);
   }
diff --git a/core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java b/core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
new file mode 100644
index 000000000000..127485239c1b
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>UNION with multiple inputs
+ * <blockquote><pre>
+ * SELECT deptno FROM emp WHERE deptno = 12
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 5
+ * UNION
+ * SELECT deptno FROM emp WHERE deptno = 6
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 10
+ *
+ * is rewritten to
+ *
+ * SELECT deptno FROM emp WHERE deptno = 12 OR deptno = 6
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 5 OR deptno = 10
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, @Nullable RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+
+    // Groups conditions by their source relational node and input position.
+    // - Key: Pair of (sourceRelNode, inputPosition)
+    //   - inputPosition is null for mergeable conditions
+    //   - inputPosition contains original index for non-mergeable inputs
+    // - Value: List of conditions
+    //
+    // For invalid conditions (non-deterministic expressions or containing subqueries),
+    // positions are tagged with their input indices to skip unmergeable inputs
+    // during map-based grouping. Other positions are set to null.
+    Map<Pair<RelNode, @Nullable Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, @Nullable Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, @Nullable Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // Single null condition indicates pass-through branch,
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      List<RexNode> condsNonNull = conds.stream().map(e -> {
+        assert e != null;
+        return e;
+      }).collect(Collectors.toList());
+
+      RexNode combinedCond =
+          combineConditions(builder, condsNonNull, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    // RelBuilder will not create 1-input SetOp
+    // and remove the distinct after a SetOp
+    buildSetOp(builder, branchCount, setOp)
+        .distinct();
+    call.transformTo(builder.build());
+  }
+
+  private static RelBuilder buildSetOp(RelBuilder builder, int count, RelNode setOp) {
+    if (setOp instanceof Union) {
+      return builder.union(false, count);
+    } else if (setOp instanceof Intersect) {
+      return builder.intersect(false, count);
+    } else if (setOp instanceof Minus) {
+      return builder.minus(false, count);
+    }
+    // unreachable
+    throw new IllegalStateException("unreachable code");
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> extractSourceAndCond(RelNode input) {
+    if (input instanceof Filter) {
+      Filter filter = (Filter) input;
+      if (!RexUtil.isDeterministic(filter.getCondition())
+          || RexUtil.SubQueryFinder.containsSubQuery(filter)) {
+        // Skip non-deterministic conditions or those containing subqueries
+        return Pair.of(input, null);
+      }
+      return Pair.of(filter.getInput().stripped(), filter.getCondition());
+    }
+    // For non-filter inputs, use TRUE literal as default condition.
+    return Pair.of(input.stripped(),
+        input.getCluster().getRexBuilder().makeLiteral(true));
+  }
+
+  /**
+   * Creates a combined condition where the first condition
+   * is kept as-is and all subsequent conditions are negated,
+   * then joined with AND operators.
+   *
+   * <p>For example, given conditions [cond1, cond2, cond3],
+   * this constructs (cond1 AND NOT(cond2) AND NOT(cond3)).
+   */
+  private static RexNode andFirstNotRest(RelBuilder builder, List<RexNode> conds) {
+    List<RexNode> allConds = new ArrayList<>();
+    allConds.add(conds.get(0));
+    for (int i = 1; i < conds.size(); i++) {
+      allConds.add(builder.not(conds.get(i)));
+    }
+    return builder.and(allConds);
+  }
+
+  /**
+   * Combines conditions according to set operation:
+   * UNION: OR combination
+   * INTERSECT: AND combination
+   * MINUS: Special handling where first source uses AND-NOT combination.
+   */
+  private RexNode combineConditions(RelBuilder builder, List<RexNode> conds,
+      SetOp setOp, boolean isFirstSource) {
+    if (setOp instanceof Union) {
+      return builder.or(conds);
+    } else if (setOp instanceof Intersect) {
+      return builder.and(conds);
+    } else if (setOp instanceof Minus) {
+      return isFirstSource
+          ? andFirstNotRest(builder, conds)
+          : builder.or(conds);
+    }
+    // unreachable
+    throw new IllegalStateException("unreachable code");
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable(singleton = false)
+  public interface Config extends RelRule.Config {
+    Config UNION = ImmutableSetOpToFilterRule.Config.builder()
+        .withMatchHandler(SetOpToFilterRule::match)
+        .build()
+        .withOperandSupplier(
+            b0 -> b0.operand(Union.class).anyInputs())
+        .as(Config.class);
+
+    Config INTERSECT = ImmutableSetOpToFilterRule.Config.builder()
+        .withMatchHandler(SetOpToFilterRule::match)
+        .build()
+        .withOperandSupplier(
+            b0 -> b0.operand(Intersect.class).anyInputs())
+        .as(Config.class);
+
+    Config MINUS = ImmutableSetOpToFilterRule.Config.builder()
+        .withMatchHandler(SetOpToFilterRule::match)
+        .build()
+        .withOperandSupplier(
+            b0 -> b0.operand(Minus.class).anyInputs())
+        .as(Config.class);
+
+    @Override default SetOpToFilterRule toRule() {
+      return new SetOpToFilterRule(this);
+    }
+
+    /** Forwards a call to {@link #onMatch(RelOptRuleCall)}. */
+    MatchHandler<SetOpToFilterRule> matchHandler();
+
+    /** Sets {@link #matchHandler()}. */
+    Config withMatchHandler(MatchHandler<SetOpToFilterRule> matchHandler);
+  }
+}
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 765e5ecd550f..2b8a583b6cc1 100644
--- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
@@ -10492,26 +10492,176 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilters() {
     final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
         + "EXCEPT\n"
         + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
     sql(sql)
         .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
-        .withRule(CoreRules.MINUS_TO_FILTER)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
         .check();
   }
 
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule2() {
+  @Test void testMinusToFilterRuleWithOneFilter() {
     final String sql = "SELECT mgr, comm FROM emp\n"
         + "EXCEPT\n"
         + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
     sql(sql)
         .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
-        .withRule(CoreRules.MINUS_TO_FILTER)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testMinusToFilterRuleNWayWithMultiSources() {
+    final String sql = "SELECT deptno FROM emp WHERE deptno > 6\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM dept WHERE deptno > 8\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM emp WHERE deptno > 12\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM dept WHERE deptno > 10\n";
+    sql(sql)
+        .withPreRule(CoreRules.MINUS_MERGE, CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testMinusToFilterRuleFourWayWithForSources() {
+    final String sql = "SELECT deptno FROM emp WHERE deptno = 12\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM dept WHERE deptno = 5\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM empnullables WHERE deptno = 6\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM deptnullables WHERE deptno = 10\n";
+    sql(sql)
+        .withPreRule(CoreRules.MINUS_MERGE, CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .checkUnchanged();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testMinusToFilterNWayWithSubquery() {
+    final String sql = "SELECT deptno FROM emp WHERE deptno > 12\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM dept WHERE deptno = 5\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM emp e1 WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM dept WHERE deptno = 10\n"
+        + "EXCEPT\n"
+        + "SELECT deptno FROM emp WHERE deptno > 20\n";
+    sql(sql)
+        .withPreRule(CoreRules.MINUS_MERGE, CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRuleWithTwoFilters() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRuleWithOneFilter() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRuleNWayWithMultiSources() {
+    final String sql = "SELECT deptno FROM emp WHERE deptno = 12\n"
+        + "UNION\n"
+        + "SELECT deptno FROM dept WHERE deptno = 5\n"
+        + "UNION\n"
+        + "SELECT deptno FROM emp WHERE deptno = 6\n"
+        + "UNION\n"
+        + "SELECT deptno FROM dept WHERE deptno = 10\n";
+    sql(sql)
+        .withPreRule(CoreRules.UNION_MERGE, CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRuleWithTwoFilters() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRuleWithOneFilter() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRuleNWayWithMultiSources() {
+    final String sql = "SELECT deptno FROM emp WHERE deptno < 12\n"
+        + "INTERSECT\n"
+        + "SELECT deptno FROM dept WHERE deptno > 5\n"
+        + "INTERSECT\n"
+        + "SELECT deptno FROM emp WHERE deptno > 6\n"
+        + "INTERSECT\n"
+        + "SELECT deptno FROM dept WHERE deptno < 10\n";
+    sql(sql)
+        .withPreRule(CoreRules.INTERSECT_MERGE, CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_FILTER_TO_FILTER)
         .check();
   }
 
diff --git a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
index 96fdbe60cbc0..a16eab93e0bd 100644
--- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
@@ -6320,6 +6320,99 @@ LogicalIntersect(all=[true])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
   LogicalProject(NAME=[CAST($1):VARCHAR(20) NOT NULL])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testIntersectToFilterRuleWithTwoFilters">
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp WHERE mgr = 12
+INTERSECT
+SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalIntersect(all=[false])
+  LogicalFilter(condition=[=($0, 12)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($1, 5)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalAggregate(group=[{0, 1}])
+  LogicalFilter(condition=[AND(=($0, 12), =($1, 5))])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testIntersectToFilterRuleWithOneFilter">
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp
+INTERSECT
+SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalIntersect(all=[false])
+  LogicalProject(MGR=[$3], COMM=[$6])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($1, 5)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalAggregate(group=[{0, 1}])
+  LogicalFilter(condition=[=($1, 5)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testIntersectToFilterRuleNWayWithMultiSources">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno < 12
+INTERSECT
+SELECT deptno FROM dept WHERE deptno > 5
+INTERSECT
+SELECT deptno FROM emp WHERE deptno > 6
+INTERSECT
+SELECT deptno FROM dept WHERE deptno < 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalIntersect(all=[false])
+  LogicalFilter(condition=[<($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 5)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[>($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[<($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalIntersect(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(6..12)])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(5..10)])])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
     </Resource>
   </TestCase>
@@ -9123,7 +9216,7 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2])
 ]]>
     </Resource>
   </TestCase>
-  <TestCase name="testMinusToFilterRule">
+  <TestCase name="testMinusToFilterRuleWithTwoFilters">
     <Resource name="sql">
       <![CDATA[SELECT mgr, comm FROM emp WHERE mgr = 12
 EXCEPT
@@ -9150,7 +9243,7 @@ LogicalAggregate(group=[{0, 1}])
 ]]>
     </Resource>
   </TestCase>
-  <TestCase name="testMinusToFilterRule2">
+  <TestCase name="testMinusToFilterRuleWithOneFilter">
     <Resource name="planBefore">
       <![CDATA[
 LogicalMinus(all=[false])
@@ -9173,6 +9266,131 @@ LogicalAggregate(group=[{0, 1}])
       <![CDATA[SELECT mgr, comm FROM emp
 EXCEPT
 SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRuleNWayWithMultiSources">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno > 6
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 8
+EXCEPT
+SELECT deptno FROM emp WHERE deptno > 12
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[>($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[>($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(6..12]])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRuleFourWayWithForSources">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno = 12
+EXCEPT
+SELECT deptno FROM dept WHERE deptno = 5
+EXCEPT
+SELECT deptno FROM empnullables WHERE deptno = 6
+EXCEPT
+SELECT deptno FROM deptnullables WHERE deptno = 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[=($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($0, 5)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[=($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMPNULLABLES]])
+  LogicalFilter(condition=[=($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPTNULLABLES]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterNWayWithSubquery">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno > 12
+EXCEPT
+SELECT deptno FROM dept WHERE deptno = 5
+EXCEPT
+SELECT deptno FROM emp e1 WHERE EXISTS (
+    SELECT 1 FROM emp e2
+    WHERE e2.comm = e1.comm)
+EXCEPT
+SELECT deptno FROM dept WHERE deptno = 10
+EXCEPT
+SELECT deptno FROM emp WHERE deptno > 20
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[>($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($0, 5)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[EXISTS({
+LogicalFilter(condition=[=($6, $cor0.COMM)])
+  LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+})])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[>($0, 20)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(12..20]])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[SEARCH($0, Sarg[5, 10])])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[EXISTS({
+LogicalFilter(condition=[=($6, $cor0.COMM)])
+  LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+})])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
     </Resource>
   </TestCase>
@@ -18455,6 +18673,98 @@ LogicalUnion(all=[false])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
   LogicalProject(DEPTNO=[$0], NAME=[$1])
     LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUnionToFilterRuleWithTwoFilters">
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp WHERE mgr = 12
+UNION
+SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalUnion(all=[false])
+  LogicalFilter(condition=[=($0, 12)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($1, 5)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalAggregate(group=[{0, 1}])
+  LogicalFilter(condition=[OR(=($0, 12), =($1, 5))])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUnionToFilterRuleWithOneFilter">
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp
+UNION
+SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalUnion(all=[false])
+  LogicalProject(MGR=[$3], COMM=[$6])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($1, 5)])
+    LogicalProject(MGR=[$3], COMM=[$6])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalAggregate(group=[{0, 1}])
+  LogicalProject(MGR=[$3], COMM=[$6])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testUnionToFilterRuleNWayWithMultiSources">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno = 12
+UNION
+SELECT deptno FROM dept WHERE deptno = 5
+UNION
+SELECT deptno FROM emp WHERE deptno = 6
+UNION
+SELECT deptno FROM dept WHERE deptno = 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalUnion(all=[false])
+  LogicalFilter(condition=[=($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($0, 5)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[=($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[=($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalUnion(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[6, 12])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[SEARCH($0, Sarg[5, 10])])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
     </Resource>
   </TestCase>


Reviews:
user: login: NobiGo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T00:45:17Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T01:45:27Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T01:47:30Z
user: login: mihaibudiu
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T03:17:38Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T03:27:22Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T03:33:28Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T03:40:57Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T03:43:06Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T03:50:03Z
user: login: NobiGo
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T10:11:41Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T14:32:05Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T14:34:27Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T14:49:42Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T14:52:10Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T14:57:33Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T21:37:59Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T23:17:05Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T23:19:48Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T23:36:38Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T22:16:50Z
user: login: NobiGo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T06:57:50Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T07:03:45Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T07:27:52Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T07:30:20Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T08:43:19Z
user: login: asolimando
body: Can you check sonar issues? Some seem legitimate
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T09:12:05Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T10:57:04Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:08:22Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:13:18Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:16:42Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:20:05Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:24:38Z
user: login: mihaibudiu
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:00:42Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:12:16Z

Review comments:
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
+      return;
+    }
+    final RelBuilder builder = call.builder();
+    final RelNode leftInput = call.rel(1);
+    final Filter rightInput = call.rel(2);
+
+    if (!RexUtil.isDeterministic(rightInput.getCondition())) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: NobiGo
body: I'm not sure if this is necessary
created_at: 2025-05-09T12:42:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
+      return;
+    }
+    final RelBuilder builder = call.builder();
+    final RelNode leftInput = call.rel(1);
+    final Filter rightInput = call.rel(2);
+
+    if (!RexUtil.isDeterministic(rightInput.getCondition())) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: Thank you for your review, I think this is a great question. My idea is that if both `inputs` have `comm=rand()`, merging filters may result in a composite `comm=rand()`, which may have an impact on the outcome. Another reason is that if there is only one `rand()` in the inputs, it may be pushed forward by other rules in the future and affect the results. So I want to be more rigorous here. If there are any issues, please help to correct them.
created_at: 2025-05-10T01:45:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
+      return;
+    }
+    final RelBuilder builder = call.builder();
+    final RelNode leftInput = call.rel(1);
+    final Filter rightInput = call.rel(2);
+
+    if (!RexUtil.isDeterministic(rightInput.getCondition())) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: Yes, I think many other rules may be broken because they copy liberally expressions without checking whether the expressions are deterministic.
created_at: 2025-05-10T01:47:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -388,9 +388,17 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule that translates a {@link Union} to {@link Filter}. */
+  public static final SetOpToFilterRule UNION_TO_FILTER =
path: core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
user: login: mihaibudiu
body: A better name would have been `UNION_FILTER_TO_FILTER`, but because we already have a rule for `MINUS`, we should keep this name, which has the same structure.
created_at: 2025-05-10T03:08:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,121 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to you under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.calcite.rel.rules;
-
-import org.apache.calcite.plan.RelOptRuleCall;
-import org.apache.calcite.plan.RelRule;
-import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.rel.core.Filter;
-import org.apache.calcite.rel.core.Minus;
-import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
-import org.apache.calcite.tools.RelBuilder;
-
-import org.immutables.value.Value;
-
-/**
- * Rule that replaces {@link Minus} operator with {@link Filter}
- * when both inputs are from the same source with only filter conditions differing.
- * Only inspect a single {@link Filter} layer in the inputs of {@link Minus}.
- * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
- * as a preprocessing step.
- *
- * <p>Example transformation:
- * <blockquote><pre>
- * SELECT mgr, comm FROM emp WHERE mgr = 12
- * EXCEPT
- * SELECT mgr, comm FROM emp WHERE comm = 5
- *
- * to
- *
- * SELECT DISTINCT mgr, comm FROM emp
- * WHERE mgr = 12 AND NOT(comm = 5)
- * </pre></blockquote>
- */
-@Value.Enclosing
-public class MinusToFilterRule
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I don't think you can delete a public class.
Just mark it `@deprecated` and point to the replacement.
I know, this is annoying.

created_at: 2025-05-10T03:10:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: I don't know what "inspect" means here, but I think this phrase is not necessary.
created_at: 2025-05-10T03:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: who should apply? 
If this rule applies, say "this rule applies"
if the user is supposed to apply, say "for nested filters, the rule ... should be used prior to invoking this one".
created_at: 2025-05-10T03:12:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: is rewritten to
created_at: 2025-05-10T03:13:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: I think this would work with more than 2 as well.
created_at: 2025-05-10T03:14:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
+      return;
+    }
+    final RelBuilder builder = call.builder();
+    final RelNode leftInput = call.rel(1);
+    final Filter rightInput = call.rel(2);
+
+    if (!RexUtil.isDeterministic(rightInput.getCondition())) {
+      return;
+    }
+
+    RelNode leftBase;
+    RexNode leftCond = null;
+    if (leftInput instanceof Filter) {
+      Filter leftFilter = (Filter) leftInput;
+      leftBase = leftFilter.getInput().stripped();
+      leftCond = leftFilter.getCondition();
+    } else {
+      leftBase = leftInput.stripped();
+    }
+
+    final RelNode rightBase = rightInput.getInput().stripped();
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    RexNode finalCond = null;
+    // Right input is Filter, right cond should be not null
+    if (setOp instanceof Union) {
+      finalCond = leftCond != null
+          ? builder.or(leftCond, rightInput.getCondition())
+          : builder.literal(true);
+    } else if (setOp instanceof Intersect) {
+      finalCond = leftCond != null
+          ? builder.and(leftCond, rightInput.getCondition())
+          : rightInput.getCondition();
+    } else if (setOp instanceof Minus) {
+      finalCond = leftCond != null
+          ? builder.and(leftCond, builder.not(rightInput.getCondition()))
+          : builder.not(rightInput.getCondition());
+    } else {
+      // Should not enter here
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: throw some exception that says "unreachable".
maybe there are other examples you can copy.
created_at: 2025-05-10T03:15:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10515,6 +10515,115 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .check();
   }
 
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
+   * Add rule for convert Minus to Filter</a>. */
+  @Test void testMinusToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "EXCEPT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "UNION\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "INTERSECT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: mihaibudiu
body: these correlated queries are more complicated than really necessary to test this rule.
why are you using them?
created_at: 2025-05-10T03:17:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10515,6 +10515,115 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .check();
   }
 
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
+   * Add rule for convert Minus to Filter</a>. */
+  @Test void testMinusToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "EXCEPT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "UNION\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "INTERSECT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: I remember you mentioned in jira that it should be applicable to correlated subqueries, so I added a case. If it is not needed, I can delete it (maybe it is really not necessary to add it here).
created_at: 2025-05-10T03:27:22Z
author_association: MEMBER
diff_hunk: @@ -10515,6 +10515,115 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .check();
   }
 
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
+   * Add rule for convert Minus to Filter</a>. */
+  @Test void testMinusToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "EXCEPT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "UNION\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "INTERSECT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: mihaibudiu
body: What i meant is maybe the rule does not apply when a predicate contains a query
created_at: 2025-05-10T03:33:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,121 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to you under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.calcite.rel.rules;
-
-import org.apache.calcite.plan.RelOptRuleCall;
-import org.apache.calcite.plan.RelRule;
-import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.rel.core.Filter;
-import org.apache.calcite.rel.core.Minus;
-import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
-import org.apache.calcite.tools.RelBuilder;
-
-import org.immutables.value.Value;
-
-/**
- * Rule that replaces {@link Minus} operator with {@link Filter}
- * when both inputs are from the same source with only filter conditions differing.
- * Only inspect a single {@link Filter} layer in the inputs of {@link Minus}.
- * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
- * as a preprocessing step.
- *
- * <p>Example transformation:
- * <blockquote><pre>
- * SELECT mgr, comm FROM emp WHERE mgr = 12
- * EXCEPT
- * SELECT mgr, comm FROM emp WHERE comm = 5
- *
- * to
- *
- * SELECT DISTINCT mgr, comm FROM emp
- * WHERE mgr = 12 AND NOT(comm = 5)
- * </pre></blockquote>
- */
-@Value.Enclosing
-public class MinusToFilterRule
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Oh, I suddenly understand what you mean. I don't want to delete the MinusToFilterRule class, but mark it as obsolete. Then I don't want to delete MINUS_TO_FILTER in CoreRules. The new SetOpToFilterRule is completely new. So the name here should be written as MINUS_FILTERTO_FILTER, and the others are similar. Do I understand it correctly now?
created_at: 2025-05-10T03:40:57Z
author_association: MEMBER
diff_hunk: @@ -10515,6 +10515,115 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .check();
   }
 
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
+   * Add rule for convert Minus to Filter</a>. */
+  @Test void testMinusToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "EXCEPT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.MINUS_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "UNION\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testUnionToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "UNION\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
+        + "    SELECT 1 FROM emp e2\n"
+        + "    WHERE e2.comm = e1.comm)";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.UNION_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE mgr = 12\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule2() {
+    final String sql = "SELECT mgr, comm FROM emp\n"
+        + "INTERSECT\n"
+        + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
+        .withRule(CoreRules.INTERSECT_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testIntersectToFilterRule3() {
+    final String sql = "SELECT mgr, comm FROM emp WHERE comm = 5\n"
+        + "INTERSECT\n"
+        + "SELECT e1.mgr, e1.comm\n"
+        + "FROM emp e1\n"
+        + "WHERE EXISTS (\n"
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: Got it, then I will prohibit subqueries.
created_at: 2025-05-10T03:43:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: Yes, it can support multiple inputs. But I am worried about a case, if input1 and input2 come from the same source, but input3 does not, do I need to handle this complex scenario? Maybe if I don't do SETOP_MERGE, there will be no such problem, and I don't need to support multiple inputs. I don't have a good idea yet.
created_at: 2025-05-10T03:50:03Z
author_association: MEMBER
diff_hunk: @@ -1,121 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to you under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.calcite.rel.rules;
-
-import org.apache.calcite.plan.RelOptRuleCall;
-import org.apache.calcite.plan.RelRule;
-import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.rel.core.Filter;
-import org.apache.calcite.rel.core.Minus;
-import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
-import org.apache.calcite.tools.RelBuilder;
-
-import org.immutables.value.Value;
-
-/**
- * Rule that replaces {@link Minus} operator with {@link Filter}
- * when both inputs are from the same source with only filter conditions differing.
- * Only inspect a single {@link Filter} layer in the inputs of {@link Minus}.
- * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
- * as a preprocessing step.
- *
- * <p>Example transformation:
- * <blockquote><pre>
- * SELECT mgr, comm FROM emp WHERE mgr = 12
- * EXCEPT
- * SELECT mgr, comm FROM emp WHERE comm = 5
- *
- * to
- *
- * SELECT DISTINCT mgr, comm FROM emp
- * WHERE mgr = 12 AND NOT(comm = 5)
- * </pre></blockquote>
- */
-@Value.Enclosing
-public class MinusToFilterRule
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: That sounds like a good approach, but I would have a _ before the TO.
created_at: 2025-05-10T14:32:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: Indeed, if you have an N-ary operation in the most general case you can "group" the inputs (in a `Map<RelOp, <List<Integer>>`) that maps each unique input to a list of input indexes, and for each such list make a single WHERE, and the result is a setop between all the lists
created_at: 2025-05-10T14:34:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,121 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to you under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.calcite.rel.rules;
-
-import org.apache.calcite.plan.RelOptRuleCall;
-import org.apache.calcite.plan.RelRule;
-import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.rel.core.Filter;
-import org.apache.calcite.rel.core.Minus;
-import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
-import org.apache.calcite.tools.RelBuilder;
-
-import org.immutables.value.Value;
-
-/**
- * Rule that replaces {@link Minus} operator with {@link Filter}
- * when both inputs are from the same source with only filter conditions differing.
- * Only inspect a single {@link Filter} layer in the inputs of {@link Minus}.
- * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
- * as a preprocessing step.
- *
- * <p>Example transformation:
- * <blockquote><pre>
- * SELECT mgr, comm FROM emp WHERE mgr = 12
- * EXCEPT
- * SELECT mgr, comm FROM emp WHERE comm = 5
- *
- * to
- *
- * SELECT DISTINCT mgr, comm FROM emp
- * WHERE mgr = 12 AND NOT(comm = 5)
- * </pre></blockquote>
- */
-@Value.Enclosing
-public class MinusToFilterRule
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Sorry, it was my writing mistake. It should be MINUS_FILTER_TO_FILTER.
created_at: 2025-05-10T14:49:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: This is a very good idea! Do I need to implement this in this PR as well?
created_at: 2025-05-10T14:52:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+
+import org.immutables.value.Value;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * Only inspect a single {@link Filter} layer in the inputs of {@link SetOp}.
+ * For inputs with nested {@link Filter}s, apply {@link CoreRules#FILTER_MERGE}
+ * as a preprocessing step.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    if (setOp.all || setOp.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: Frankly, I don't think it will be much more work.
But you can do it in two PRs if you want.
created_at: 2025-05-10T14:57:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: you should also describe what happens if there are multiple inputs here.
only one setop will be sufficient for that case.
created_at: 2025-05-11T21:18:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: what makes a position "invalid?"
you haven't explained yet
created_at: 2025-05-11T21:19:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: the checker is unhappy about your missing annotations
created_at: 2025-05-11T21:19:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: the distinct is no longer necessary if you do the setop.
if there is only one group, it is
created_at: 2025-05-11T21:20:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: you should not do the setop if there is only one input left
created_at: 2025-05-11T21:21:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
+        .distinct();
+    call.transformTo(builder.build());
+  }
+
+  private static RelBuilder buildSetOp(RelBuilder builder, int count, RelNode setOp) {
+    if (setOp instanceof Union) {
+      return builder.union(false, count);
+    } else if (setOp instanceof Intersect) {
+      return builder.intersect(false, count);
+    } else if (setOp instanceof Minus) {
+      return builder.minus(false, count);
+    }
+    // unreachable
+    throw new IllegalStateException("unreachable code");
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> extractSourceAndCond(RelNode input) {
+    if (input instanceof Filter) {
+      Filter filter = (Filter) input;
+      if (!RexUtil.isDeterministic(filter.getCondition())
+          || RexUtil.SubQueryFinder.containsSubQuery(filter)) {
+        // If filter has non-deterministic expressions or subqueries,
+        // just return current input and null cond.
+        return Pair.of(input, null);
+      }
+      return Pair.of(filter.getInput().stripped(), filter.getCondition());
+    }
+    return Pair.of(input.stripped(),
+        input.getCluster().getRexBuilder().makeLiteral(true));
+  }
+
+  private static RexNode andFirstNotRest(RelBuilder builder, List<RexNode> conds) {
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: a short comment would help, although the function is pretty clear
builds `conds.get(0) && !conds.get(1) && ... `
created_at: 2025-05-11T21:22:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: but maybe the builder takes care of both of these?
created_at: 2025-05-11T21:37:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: Yes, the builder can help me to do this.
created_at: 2025-05-11T23:17:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: I have Completed the modification of these comments.
created_at: 2025-05-11T23:19:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: Please wait for me to fix the ci error.
created_at: 2025-05-11T23:36:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: xiedeyantu
body: @mihaibudiu I have fixed CI errors. Can you please help me to have a look?
created_at: 2025-05-12T22:16:50Z
author_association: MEMBER
diff_hunk: @@ -388,7 +388,20 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule that translates a {@link Union} to {@link Filter}. */
+  public static final SetOpToFilterRule UNION_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.UNION.toRule();
+
+  /** Rule that translates a {@link Intersect} to {@link Filter}. */
+  public static final SetOpToFilterRule INTERSECT_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.INTERSECT.toRule();
+
   /** Rule that translates a {@link Minus} to {@link Filter}. */
+  public static final SetOpToFilterRule MINUS_FILTER_TO_FILTER =
+      SetOpToFilterRule.Config.MINUS.toRule();
+
+  /** Rule that sames as {@link CoreRules#MINUS_FILTER_TO_FILTER},
+   * But it is deprecated. */
path: core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
user: login: NobiGo
body: Use ` @Deprecated` 
created_at: 2025-05-13T06:27:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>UNION with multiple inputs
+ * <blockquote><pre>
+ * SELECT deptno FROM emp WHERE deptno = 12
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 5
+ * UNION
+ * SELECT deptno FROM emp WHERE deptno = 6
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 10
+ *
+ * is rewritten to
+ *
+ * SELECT deptno FROM emp WHERE deptno = 12 OR deptno = 5
+ * UNION
+ * SELECT deptno FROM emp WHERE deptno = 5 OR deptno = 10
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: NobiGo
body: `deptno = 6`  OR `dept`?
created_at: 2025-05-13T06:30:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9173,6 +9266,131 @@ LogicalAggregate(group=[{0, 1}])
       <![CDATA[SELECT mgr, comm FROM emp
 EXCEPT
 SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRule3">
path: core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
user: login: NobiGo
body: Why `Minus`?
created_at: 2025-05-13T06:42:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9173,6 +9266,131 @@ LogicalAggregate(group=[{0, 1}])
       <![CDATA[SELECT mgr, comm FROM emp
 EXCEPT
 SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRule3">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno > 6
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 8
+EXCEPT
+SELECT deptno FROM emp WHERE deptno > 12
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[>($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[>($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(6..12]])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
path: core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
user: login: NobiGo
body: `condition=[>($0, 8)]` this condition is right?
created_at: 2025-05-13T06:57:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9173,6 +9266,131 @@ LogicalAggregate(group=[{0, 1}])
       <![CDATA[SELECT mgr, comm FROM emp
 EXCEPT
 SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRule3">
path: core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
user: login: xiedeyantu
body: Sorry, I didn't understand this comment. Are you asking why Minus has added these cases while others haven't? At first, I thought that there might be many cases, and Minus might be the most complex situation, so I prioritized presenting this one. If there are any other items that need to be added, I can add them, mainly because I am worried about too many cases.
created_at: 2025-05-13T07:03:45Z
author_association: MEMBER
diff_hunk: @@ -9173,6 +9266,131 @@ LogicalAggregate(group=[{0, 1}])
       <![CDATA[SELECT mgr, comm FROM emp
 EXCEPT
 SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRule3">
+    <Resource name="sql">
+      <![CDATA[SELECT deptno FROM emp WHERE deptno > 6
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 8
+EXCEPT
+SELECT deptno FROM emp WHERE deptno > 12
+EXCEPT
+SELECT deptno FROM dept WHERE deptno > 10
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[>($0, 6)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+  LogicalFilter(condition=[>($0, 12)])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 10)])
+    LogicalProject(DEPTNO=[$0])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalMinus(all=[false])
+  LogicalFilter(condition=[SEARCH($0, Sarg[(6..12]])])
+    LogicalProject(DEPTNO=[$7])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+  LogicalFilter(condition=[>($0, 8)])
path: core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
user: login: xiedeyantu
body: Original SQL:
```
SELECT deptno FROM emp WHERE deptno > 6
EXCEPT
SELECT deptno FROM dept WHERE deptno > 8
EXCEPT
SELECT deptno FROM emp WHERE deptno > 12
EXCEPT
SELECT deptno FROM dept WHERE deptno > 10
```
Firstly can be convert to:
```
SELECT deptno FROM emp WHERE deptno > 6 and deptno <= 12
EXCEPT
(
SELECT deptno FROM dept WHERE deptno > 8
UNION
SELECT deptno FROM dept WHERE deptno > 10
)
```
Secondly can be convert to:
```
SELECT deptno FROM emp WHERE deptno > 6 and deptno <= 12
EXCEPT
SELECT deptno FROM dept WHERE deptno > 8
```
So it should be consistent with the current plan.
created_at: 2025-05-13T07:27:51Z
author_association: MEMBER
diff_hunk: @@ -10511,7 +10511,157 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
     sql(sql)
         .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
-        .withRule(CoreRules.MINUS_TO_FILTER)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testMinusToFilterRule3() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: Can we come up with a more informative suffix than an incremental ID? If tests are well shaped like in this case, you are adding them as they cover some specific situation (multiple operands, null values, etc.). It's very informative to add this into the test name, rather than having the reader to try to infer it from the example (sometimes it might not be very evident). If you have a really hard time seeing the difference between one or more test cases, then it's an indication that they might be redundant.
created_at: 2025-05-13T07:30:20Z
author_association: MEMBER
diff_hunk: @@ -10511,7 +10511,157 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         + "SELECT mgr, comm FROM emp WHERE comm = 5\n";
     sql(sql)
         .withPreRule(CoreRules.PROJECT_FILTER_TRANSPOSE)
-        .withRule(CoreRules.MINUS_TO_FILTER)
+        .withRule(CoreRules.MINUS_FILTER_TO_FILTER)
+        .check();
+  }
+
+  /** Test case of
+   * <a href="https://issues.apache.org/jira/browse/CALCITE-7002">[CALCITE-7002]
+   * Create an optimization rule to eliminate UNION
+   * from the same source with different filters</a>. */
+  @Test void testMinusToFilterRule3() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: Done!
created_at: 2025-05-13T08:43:19Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: ```suggestion
  @Test void testMinusToFilterRuleWithTwoFilters() {
```
created_at: 2025-05-13T09:10:47Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: The local test passes, but pr-head always fails. I will push the code later. Sorry.
created_at: 2025-05-13T10:57:04Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: https://ci-builds.apache.org/job/Calcite/job/Calcite-sonar/job/PR-4366/lastBuild/cloudbees-pipeline-explorer/?line=423 highlights that computed and expected file for `RelOptRulesTest` don't match.

I haven't tried locally, but CI seems consistently broken, we can't merge anything until we fix it.

If you have some spare cycles it'd help if you could take a look!
created_at: 2025-05-13T12:08:22Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: Thanks, I saw this error. But it can indeed pass through locally. I agree with your idea that we cannot merge the PR of CI. Give me some time, and I will find a way to get CI approved.
created_at: 2025-05-13T12:13:18Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: It's very weird that it passes locally and fails in CI, it would help tracking when this started to help figure out the source of the issue.
created_at: 2025-05-13T12:16:42Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: I am now renaming this branch to a new branch and submitting a new PR. The new PR can also start a rerun from this PR. I will try to see if it can pass.
created_at: 2025-05-13T12:20:05Z
author_association: MEMBER
diff_hunk: @@ -10492,7 +10492,7 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
   /** Test case of
    * <a href="https://issues.apache.org/jira/browse/CALCITE-6973">[CALCITE-6973]
    * Add rule for convert Minus to Filter</a>. */
-  @Test void testMinusToFilterRule() {
+  @Test void testMinusToFilterRuleWithTwoFilter() {
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: Now the PR head CI has been approved. I'm not sure if the time interval between two submissions was too short to trigger a rerun or if there was an impact from caching. This is the second time I have encountered this problem. The solution is to create a new PR with the same commits.
created_at: 2025-05-13T12:24:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>UNION with multiple inputs
+ * <blockquote><pre>
+ * SELECT deptno FROM emp WHERE deptno = 12
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 5
+ * UNION
+ * SELECT deptno FROM emp WHERE deptno = 6
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 10
+ *
+ * is rewritten to
+ *
+ * SELECT deptno FROM emp WHERE deptno = 12 OR deptno = 6
+ * UNION
+ * SELECT deptno FROM dept WHERE deptno = 5 OR deptno = 10
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, @Nullable RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+
+    // Groups conditions by their source relational node and input position.
+    // - Key: Pair of (sourceRelNode, inputPosition)
+    //   - inputPosition is null for mergeable conditions
+    //   - inputPosition contains original index for non-mergeable inputs
+    // - Value: List of nullable conditions
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: I am reading here "nullable condition" as a "condition expression that has a nullable type".
But this is not what you mean.
I would just delete "nullable"
created_at: 2025-05-13T14:58:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,241 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.rules;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Intersect;
+import org.apache.calcite.rel.core.Minus;
+import org.apache.calcite.rel.core.SetOp;
+import org.apache.calcite.rel.core.Union;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.Pair;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Rule that replaces {@link SetOp} operator with {@link Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ * For nested filters, the rule {@link CoreRules#FILTER_MERGE}
+ * should be used prior to invoking this one.
+ *
+ * <p>Example:
+ *
+ * <p>UNION
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * UNION
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 OR comm = 5
+ * </pre></blockquote>
+ *
+ * <p>INTERSECT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * INTERSECT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND comm = 5
+ * </pre></blockquote>
+ *
+ * <p>EXCEPT
+ * <blockquote><pre>
+ * SELECT mgr, comm FROM emp WHERE mgr = 12
+ * EXCEPT
+ * SELECT mgr, comm FROM emp WHERE comm = 5
+ *
+ * is rewritten to
+ *
+ * SELECT DISTINCT mgr, comm FROM emp
+ * WHERE mgr = 12 AND NOT(comm = 5)
+ * </pre></blockquote>
+ */
+@Value.Enclosing
+public class SetOpToFilterRule
+    extends RelRule<SetOpToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an SetOpToFilterRule. */
+  protected SetOpToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    config.matchHandler().accept(this, call);
+  }
+
+  private void match(RelOptRuleCall call) {
+    final SetOp setOp = call.rel(0);
+    final List<RelNode> inputs = setOp.getInputs();
+    if (setOp.all || inputs.size() < 2) {
+      return;
+    }
+
+    final RelBuilder builder = call.builder();
+    Pair<RelNode, RexNode> first = extractSourceAndCond(inputs.get(0).stripped());
+    // Map to store conditions grouped by <sourceRelNode, inputPosition>.
+    // Valid cond positions are set to null and others are tagged with
+    // input indices for map-based grouping.
+    Map<Pair<RelNode, Integer>, List<@Nullable RexNode>> sourceToConds =
+        new LinkedHashMap<>();
+
+    RelNode firstSource = first.left;
+    sourceToConds.computeIfAbsent(Pair.of(firstSource, null),
+        k -> new ArrayList<>()).add(first.right);
+
+    for (int i = 1; i < inputs.size(); i++) {
+      final RelNode input = inputs.get(i).stripped();
+      final Pair<RelNode, @Nullable RexNode> pair = extractSourceAndCond(input);
+      sourceToConds.computeIfAbsent(Pair.of(pair.left, pair.right != null ? null : i),
+          k -> new ArrayList<>()).add(pair.right);
+    }
+
+    if (sourceToConds.size() == inputs.size()) {
+      return;
+    }
+
+    int branchCount = 0;
+    for (Map.Entry<Pair<RelNode, Integer>, List<@Nullable RexNode>> entry
+        : sourceToConds.entrySet()) {
+      Pair<RelNode, Integer> left = entry.getKey();
+      List<@Nullable RexNode> conds = entry.getValue();
+      // If the condition is met (refer to extractSourceAndCond method),
+      // directly add its corresponding input to the new inputs list.
+      if (conds.size() == 1 && conds.get(0) == null) {
+        builder.push(left.left);
+        branchCount++;
+        continue;
+      }
+
+      RexNode combinedCond =
+          combineConditions(builder, conds, setOp, left.left == firstSource);
+
+      builder.push(left.left)
+          .filter(combinedCond);
+      branchCount++;
+    }
+
+    buildSetOp(builder, branchCount, setOp)
path: core/src/main/java/org/apache/calcite/rel/rules/SetOpToFilterRule.java
user: login: mihaibudiu
body: can you also add a comment about the fact that the builder will not create 1-input setop and remove the distinct after a setop?
created_at: 2025-05-13T15:12:16Z
author_association: CONTRIBUTOR

PR comments:
user: login: mihaibudiu
created_at: 2025-05-10T14:59:18Z
body: If you don't plan to generalize this, let me know and we'll merge it.
Otherwise the new commit may also make these failing tests pass, they seem to be network-related.
author_association: CONTRIBUTOR
user: login: xiedeyantu
created_at: 2025-05-10T15:02:45Z
body: > If you don't plan to generalize this, let me know and we'll merge it. Otherwise the new commit may also make these failing tests pass, they seem to be network-related.

Please wait me to implement your idea.
author_association: MEMBER
user: login: xiedeyantu
created_at: 2025-05-11T16:26:45Z
body: @mihaibudiu I have implemented the n-ary logic, please help me check if it meets expectations.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-05-13T08:21:40Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=apache_calcite&pullRequest=4366) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [5 New issues](https://sonarcloud.io/project/issues?id=apache_calcite&pullRequest=4366&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=apache_calcite&pullRequest=4366&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=apache_calcite&pullRequest=4366&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [92.9% Coverage on New Code](https://sonarcloud.io/component_measures?id=apache_calcite&pullRequest=4366&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=apache_calcite&pullRequest=4366&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apache_calcite&pullRequest=4366)


author_association: NONE
user: login: mihaibudiu
created_at: 2025-05-13T15:00:56Z
body: I think you can squash the commits
author_association: CONTRIBUTOR
user: login: xiedeyantu
created_at: 2025-05-13T15:59:43Z
body: > I think you can squash the commits

I have already modified and squash according to the comments.
author_association: MEMBER