Repository: apache/calcite
Pull request #4322: [CALCITE-6973] Add rule to convert Minus to Filter
Author: login: xiedeyantu
Labels: name: LGTM-will-merge-soon
description: Overall PR looks OK. Only minor things left.
Created: 2025-04-20T06:24:22Z | Merged: 2025-04-25T00:24:03Z

Please see: [CALCITE-6973](https://issues.apache.org/jira/browse/CALCITE-6973)


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 fb4fdd5ea78d..be7d8998b8aa 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
@@ -383,6 +383,10 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule that translates a {@link Minus} to {@link Filter}. */
+  public static final MinusToFilterRule MINUS_TO_FILTER =
+      MinusToFilterRule.Config.DEFAULT.toRule();
+
   /** Rule that translates a distinct
    * {@link Minus} into a group of operators
    * composed of {@link Union}, {@link Aggregate}, etc. */
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
new file mode 100644
index 000000000000..2a04991b0bac
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
@@ -0,0 +1,121 @@
+/*
+ * 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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    // Right input is Filter, right cond should be not null
+    final RexNode finalCond = leftCond != null
+        ? builder.and(leftCond, builder.not(rightInput.getCondition()))
+        : builder.not(rightInput.getCondition());
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableMinusToFilterRule.Config.of()
+        .withOperandFor(Minus.class, RelNode.class, Filter.class);
+
+    @Override default MinusToFilterRule toRule() {
+      return new MinusToFilterRule(this);
+    }
+
+    /** Defines an operand tree for the given classes. */
+    default Config withOperandFor(Class<? extends Minus> minusClass,
+        Class<? extends RelNode> relNodeClass, Class<? extends Filter> filterClass) {
+      return withOperandSupplier(
+          b0 -> b0.operand(minusClass).inputs(
+              b1 -> b1.operand(relNodeClass).anyInputs(),
+              b2 -> b2.operand(filterClass).anyInputs()))
+          .as(Config.class);
+    }
+  }
+}
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 90680ca52813..0ab315d7017e 100644
--- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
@@ -10421,4 +10421,30 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    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)
+        .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 180f98499c2c..0f2a9704af65 100644
--- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
@@ -9012,6 +9012,59 @@ LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2])
           LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], $f3=[1])
             LogicalFilter(condition=[=($7, 30)])
               LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testMinusToFilterRule">
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp WHERE mgr = 12
+EXCEPT
+SELECT mgr, comm FROM emp WHERE comm = 5
+]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(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="testMinusToFilterRule2">
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalMinus(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>
+    <Resource name="sql">
+      <![CDATA[SELECT mgr, comm FROM emp
+EXCEPT
+SELECT mgr, comm FROM emp WHERE comm = 5
 ]]>
     </Resource>
   </TestCase>


Reviews:
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-20T18:51:20Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-20T22:46:19Z
user: login: NobiGo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T01:28:58Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T01:37:33Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T03:46:51Z
user: login: NobiGo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T05:45:54Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T23:59:12Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:18:49Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T00:20:09Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:20:58Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:24:08Z
user: login: mihaibudiu
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T01:17:18Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T02:22:20Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T04:21:23Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T04:23:12Z
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T06:03:40Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T06:43:49Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T10:45:00Z
user: login: NobiGo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:09:18Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:27:11Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T12:36:29Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:06:09Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:36:08Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:36:24Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:36:34Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T06:55:47Z
user: login: asolimando
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T06:56:47Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T07:07:41Z
user: login: asolimando
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T07:18:13Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T07:47:10Z

Review comments:
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableMinusToFilterRule.Config.of()
+        .withOperandFor(Minus.class);
+
+    @Override default MinusToFilterRule toRule() {
+      return new MinusToFilterRule(this);
+    }
+
+    /** Limit how much complexity can increase during merging.
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I am not sure this is necessary here, because the result is always linear in the size of the input. 
created_at: 2025-04-20T18:49:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: where is the check that both filters produce the same fields?
created_at: 2025-04-20T18:51:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: I understand that there is no need to judge the same column, as long as the condition of `right` is reversed. WDYT
created_at: 2025-04-20T22:46:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: NobiGo
body: How about `SELECT mgr, comm FROM emp WHERE mgr = 12 EXCEPT SELECT mgr1, comm1 FROM emp WHERE comm = 5`? We need to ensure that the two inputs return the same columns. Only then will this Rule be valid.
created_at: 2025-04-21T01:24:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Maybe [here](https://github.com/apache/calcite/pull/4322/files/1a743cc2f3947a79a084bd1fe433a2740961a2cf#diff-7dde415fabdd836a8181e96f6405a572bd5a9c07d67394d596dbbd8039e1454aR80) can solve this question？I will check if it comes from the same source.
created_at: 2025-04-21T01:37:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableMinusToFilterRule.Config.of()
+        .withOperandFor(Minus.class);
+
+    @Override default MinusToFilterRule toRule() {
+      return new MinusToFilterRule(this);
+    }
+
+    /** Limit how much complexity can increase during merging.
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Done!
created_at: 2025-04-21T03:46:51Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,137 @@
+/*
+ * 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.RelOptUtil;
+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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: NobiGo
body: Get it.
created_at: 2025-04-21T05:45:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I would call this "getUnfilteredInput"
Note that "source" rather implies a table, as opposed to an input of the current dataflow node.
created_at: 2025-04-21T23:57:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: can this ever not be a Filter?
created_at: 2025-04-21T23:58:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: why accept non-filters and treat them like "true"?
this seems to do unnecessary rewrites
created_at: 2025-04-21T23:59:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: It can indeed be other operators. I want to convert the plan through some pre-rules first, and then make this rule effective. The problem to be dealt with here is similar to the problem dealt with by some existing rules.
created_at: 2025-04-22T00:18:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: you can match on Minus(Filter, Filter)
created_at: 2025-04-22T00:20:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Yes, it should be an empty values.
created_at: 2025-04-22T00:20:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,130 @@
+/*
+ * 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 org.apache.calcite.rel.core.Minus}
+ * operator with {@link org.apache.calcite.rel.core.Filter}
+ * when both inputs are from the same source with only filter conditions differing.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
+      return;
+    }
+
+    final RelNode leftInput = minus.getInput(0);
+    final RelNode rightInput = minus.getInput(1);
+
+    final RelBuilder builder = call.builder();
+
+    final RelNode leftBase = getBaseSource(leftInput);
+    final RelNode rightBase = getBaseSource(rightInput);
+
+    RexNode rightCond = extractFilterCond(rightInput);
+    if (!RexUtil.isDeterministic(rightCond)) {
+      return;
+    }
+
+    // Verify they come from the same source
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    final RexNode finalCond =
+        builder.and(extractFilterCond(leftInput), builder.not(rightCond));
+
+    builder.push(leftBase)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * Returns the base source of the given node,
+   * skipping first filter.
+   */
+  private static RelNode getBaseSource(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getInput();
+    } else {
+      return node;
+    }
+  }
+
+  private static RexNode extractFilterCond(RelNode node) {
+    RelNode stripped = node.stripped();
+    if (stripped instanceof Filter) {
+      return ((Filter) stripped).getCondition();
+    }
+    return node.getCluster().getRexBuilder().makeLiteral(true);
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: > you can match on Minus(Filter, Filter)

Great idea, this is better.
created_at: 2025-04-22T00:24:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I expect size cannot be != 2, since the rule would not be applied
So this is fine, but it could also be `assert getInputs().size() == 2;`
created_at: 2025-04-23T01:13:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: here you handle nested filters; perhaps you should document this in the JavaDoc.
created_at: 2025-04-23T01:15:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: I only considered the 2-way scenario. Changing to assert may allow non-2-way scenarios to enter the rule, which may cause other input filters to be ignored. I think that handwritten SQL generally does not have n-way scenarios, so 2-way is supported for the time being. Do I need to make any further changes?
created_at: 2025-04-23T02:22:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: This test can build 3-way Minus
```
  @Test void testMinusToFilterRule2() {
    final Function<RelBuilder, RelNode> relFn = b -> b
        .scan("EMP")
        .project(b.field("MGR"), b.field("COMM"))
        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
        .scan("EMP")
        .project(b.field("MGR"), b.field("COMM"))
        .filter(b.lessThan(b.field("MGR"), b.literal(10)))
        .scan("EMP")
        .project(b.field("MGR"), b.field("COMM"))
        .filter(b.lessThan(b.field("MGR"), b.literal(9)))
        .minus(false, 3)
        .build();

    relFn(relFn)
        .withRule(CoreRules.MINUS_TO_FILTER)
        .check();
  }
```
created_at: 2025-04-23T04:21:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Done!
created_at: 2025-04-23T04:23:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I mean the JavaDoc for the rule.
created_at: 2025-04-23T06:03:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: mihaibudiu
body: I said that withOperandSupplier will only accept minus with two inputs. isn't that right?
created_at: 2025-04-23T06:03:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.getInputs().size() != 2) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: It only enforces type constraints on the first two inputs, without restricting the total number of inputs.
created_at: 2025-04-23T06:43:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,133 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Done!
created_at: 2025-04-23T10:45:00Z
author_association: MEMBER
diff_hunk: @@ -381,6 +381,10 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule to translates a {@link Minus} to {@link Filter}. */
path: core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
user: login: NobiGo
body: Rule that...
created_at: 2025-04-23T11:27:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10407,4 +10407,38 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    final Function<RelBuilder, RelNode> relFn = b -> b
+        .scan("EMP")
+        .project(b.field("MGR"), b.field("COMM"))
+        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: NobiGo
body: We have `FilterMergeRule` to merge Filter, so I think this Rule should focus on Minus to Filter. There is no need to do everything under this Rule.
created_at: 2025-04-23T12:07:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,141 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ * This rule enables merging all filter conditions into a composite condition
+ * when processing consecutively nested filters.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * This method iteratively traverses the input node, stripping away
+   * and extracting conditions from Filter operators at each level,
+   * ultimately merging all conditions into a composite condition
+   * while returning the first encountered non-Filter operator.
+   */
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
+      Filter filter = (Filter) stripped;
+      if (cond == null) {
+        cond = filter.getCondition();
+      } else {
+        cond =
+            RexUtil.composeConjunction(builder, ImmutableList.of(cond, filter.getCondition()));
+      }
+      stripped = filter.getInput().stripped();
+    }
+    return Pair.of(stripped, cond);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableMinusToFilterRule.Config.of()
+        .withOperandFor(Minus.class, RelNode.class, Filter.class);
+
+    @Override default MinusToFilterRule toRule() {
+      return new MinusToFilterRule(this);
+    }
+
+    /** Defines an operand tree for the given classes. */
+    default Config withOperandFor(Class<? extends Minus> minusClass,
+        Class<? extends RelNode> relNodeClass, Class<? extends Filter> filterClass) {
+      return withOperandSupplier(
+          b0 -> b0.operand(minusClass).inputs(
+              b1 -> b1.operand(relNodeClass).anyInputs(),
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: NobiGo
body: We should have a test case covering RelNode and Filter. Now only include Filter and Filter.
created_at: 2025-04-23T12:09:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10407,4 +10407,38 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    final Function<RelBuilder, RelNode> relFn = b -> b
+        .scan("EMP")
+        .project(b.field("MGR"), b.field("COMM"))
+        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: I strongly agree, individual rules should be kept as simple as possible and should rely on other rules whenever it makes sense like in this case
created_at: 2025-04-23T12:27:10Z
author_association: MEMBER
diff_hunk: @@ -10407,4 +10407,38 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    final Function<RelBuilder, RelNode> relFn = b -> b
+        .scan("EMP")
+        .project(b.field("MGR"), b.field("COMM"))
+        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: One simple test case could cover the "simple" translation, a second test case could actually show the interaction between the two rules (it should have a result comparable to what the rule provides now).
created_at: 2025-04-23T12:36:16Z
author_association: MEMBER
diff_hunk: @@ -10407,4 +10407,38 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    final Function<RelBuilder, RelNode> relFn = b -> b
+        .scan("EMP")
+        .project(b.field("MGR"), b.field("COMM"))
+        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: Thank @NobiGo and @asolimando for your response. The reason I wrote it this way is that the `left input` can be a `filter` or not, or even multiple nested `filter`s. Since the input of `MINUS` needs to be traversed regardless, I took the opportunity to implement this simple capability. If everyone thinks it's redundant, I can make the rule more pure.
created_at: 2025-04-23T13:06:09Z
author_association: MEMBER
diff_hunk: @@ -381,6 +381,10 @@ private CoreRules() {}
   public static final IntersectToSemiJoinRule INTERSECT_TO_SEMI_JOIN =
           IntersectToSemiJoinRule.Config.DEFAULT.toRule();
 
+  /** Rule to translates a {@link Minus} to {@link Filter}. */
path: core/src/main/java/org/apache/calcite/rel/rules/CoreRules.java
user: login: xiedeyantu
body: Done!

created_at: 2025-04-23T13:36:08Z
author_association: MEMBER
diff_hunk: @@ -10407,4 +10407,38 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .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() {
+    final Function<RelBuilder, RelNode> relFn = b -> b
+        .scan("EMP")
+        .project(b.field("MGR"), b.field("COMM"))
+        .filter(b.lessThan(b.field("MGR"), b.literal(20)))
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: @NobiGo @asolimando Done!
created_at: 2025-04-23T13:36:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,141 @@
+/*
+ * 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.RexBuilder;
+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 com.google.common.collect.ImmutableList;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+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.
+ * This rule enables merging all filter conditions into a composite condition
+ * when processing consecutively nested filters.
+ *
+ * <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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final Pair<RelNode, @Nullable RexNode> leftBase = getUnfilteredInputAndMergeCond(leftInput);
+    final Pair<RelNode, @Nullable RexNode> rightBase = getUnfilteredInputAndMergeCond(rightInput);
+    if (!leftBase.left.equals(rightBase.left)) {
+      return;
+    }
+
+    // Right input is Filter, right cond should be not null
+    assert rightBase.right != null;
+    final RexNode finalCond = leftBase.right != null
+        ? builder.and(leftBase.right, builder.not(rightBase.right))
+        : builder.not(rightBase.right);
+
+    builder.push(leftBase.left)
+        .filter(finalCond)
+        .distinct();
+
+    call.transformTo(builder.build());
+  }
+
+  /**
+   * This method iteratively traverses the input node, stripping away
+   * and extracting conditions from Filter operators at each level,
+   * ultimately merging all conditions into a composite condition
+   * while returning the first encountered non-Filter operator.
+   */
+  private static Pair<RelNode, @Nullable RexNode> getUnfilteredInputAndMergeCond(
+      final RelNode node) {
+    RexBuilder builder = node.getCluster().getRexBuilder();
+    RelNode stripped = node.stripped();
+    RexNode cond = null;
+    while (stripped instanceof Filter) {
+      Filter filter = (Filter) stripped;
+      if (cond == null) {
+        cond = filter.getCondition();
+      } else {
+        cond =
+            RexUtil.composeConjunction(builder, ImmutableList.of(cond, filter.getCondition()));
+      }
+      stripped = filter.getInput().stripped();
+    }
+    return Pair.of(stripped, cond);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableMinusToFilterRule.Config.of()
+        .withOperandFor(Minus.class, RelNode.class, Filter.class);
+
+    @Override default MinusToFilterRule toRule() {
+      return new MinusToFilterRule(this);
+    }
+
+    /** Defines an operand tree for the given classes. */
+    default Config withOperandFor(Class<? extends Minus> minusClass,
+        Class<? extends RelNode> relNodeClass, Class<? extends Filter> filterClass) {
+      return withOperandSupplier(
+          b0 -> b0.operand(minusClass).inputs(
+              b1 -> b1.operand(relNodeClass).anyInputs(),
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Done!
created_at: 2025-04-23T13:36:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+/*
+ * 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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final RelNode leftBase = leftInput instanceof Filter
+        ? ((Filter) leftInput).getInput().stripped()
+        : leftInput.stripped();
+    final RelNode rightBase = rightInput.getInput().stripped();
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    RexNode leftCond = leftInput instanceof Filter
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: asolimando
body: Nit: You are checking twice this condition in a complex expression, I think it would improve readability to extract the boolean variable and use that instead. WDYT?
created_at: 2025-04-24T06:55:46Z
author_association: MEMBER
diff_hunk: @@ -10407,4 +10407,30 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .check();
+  }
+
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: asolimando
body: Can we add a test case involving `FILTER_MERGE` rule too just to check that we can cover equivalent transformations as the previous version?
created_at: 2025-04-24T06:56:47Z
author_association: MEMBER
diff_hunk: @@ -10407,4 +10407,30 @@ private void checkLoptOptimizeJoinRule(LoptOptimizeJoinRule rule) {
         .withRule(CoreRules.JOIN_CONDITION_PUSH)
         .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 testMinusToFilterRule() {
+    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)
+        .check();
+  }
+
path: core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
user: login: xiedeyantu
body: In my opinion, it may not be necessary to add this case. This case may only show whether the filter is merged, which has little to do with this PR.
created_at: 2025-04-24T07:07:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,118 @@
+/*
+ * 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
+    extends RelRule<MinusToFilterRule.Config>
+    implements TransformationRule {
+
+  /** Creates an MinusToFilterRule. */
+  protected MinusToFilterRule(Config config) {
+    super(config);
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Minus minus = call.rel(0);
+    if (minus.all || minus.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;
+    }
+
+    final RelNode leftBase = leftInput instanceof Filter
+        ? ((Filter) leftInput).getInput().stripped()
+        : leftInput.stripped();
+    final RelNode rightBase = rightInput.getInput().stripped();
+    if (!leftBase.equals(rightBase)) {
+      return;
+    }
+
+    RexNode leftCond = leftInput instanceof Filter
path: core/src/main/java/org/apache/calcite/rel/rules/MinusToFilterRule.java
user: login: xiedeyantu
body: Done!
created_at: 2025-04-24T07:47:10Z
author_association: MEMBER

PR comments:
user: login: xiedeyantu
created_at: 2025-04-22T22:55:53Z
body: @mihaibudiu I have refactored this pr, please take another look. Thanks!
author_association: MEMBER
user: login: mihaibudiu
created_at: 2025-04-23T17:38:19Z
body: I think it is a good design principle to have each rule be as simple as possible, and not do work that another rule can do.
However, there is a balancing act in practice, because the complexity of the search process is exponential in the number of rules that can be applied at each step, so there may be performance benefits in having a rule perform optimizations that it *knows* for sure are beneficial - rather than hoping the optimizer will guess what to do next.
author_association: CONTRIBUTOR
user: login: xiedeyantu
created_at: 2025-04-23T18:24:00Z
body: > I think it is a good design principle to have each rule be as simple as possible, and not do work that another rule can do. However, there is a balancing act in practice, because the complexity of the search process is exponential in the number of rules that can be applied at each step, so there may be performance benefits in having a rule perform optimizations that it _knows_ for sure are beneficial - rather than hoping the optimizer will guess what to do next.

I agree with @mihaibudiu 's opinion, what do you think? @asolimando @NobiGo 
author_association: MEMBER
user: login: mihaibudiu
created_at: 2025-04-23T18:26:49Z
body: Notice that I said there is an argument for both cases, but I didn't say which one is right.

author_association: CONTRIBUTOR
user: login: mihaibudiu
created_at: 2025-04-23T18:28:35Z
body: Maybe the right solution is to make the optimizer more sophisticated and keep the rules simple.
There is some work on this topic: e.g., 
Bailu Ding, Vivek Narasayya and Surajit Chaudhuri (2024), “Extensible Query Optimizers in Practice”, Foundations and Trends® in Databases: Vol. 14, No. 3-4, pp 186–402. DOI: 10.1561/1900000077
author_association: CONTRIBUTOR
user: login: xiedeyantu
created_at: 2025-04-23T18:39:02Z
body: > Notice that I said there is an argument for both cases, but I didn't say which one is right.

I understand what you mean. I actually rewrote it according to their ideas to see what most people think. I am just expressing my thoughts which are the same as what you mentioned above.
author_association: MEMBER
user: login: asolimando
created_at: 2025-04-24T07:00:36Z
body: > I think it is a good design principle to have each rule be as simple as possible, and not do work that another rule can do. However, there is a balancing act in practice, because the complexity of the search process is exponential in the number of rules that can be applied at each step, so there may be performance benefits in having a rule perform optimizations that it _knows_ for sure are beneficial - rather than hoping the optimizer will guess what to do next.

I agree on your statement, I have baked into rules some "transformations" (e.g., predicate simplifications despite there is a rule to do that) when we knew we had to do it. In this case, `FILTER_MERGE` rule is part of the standard rule set in Calcite and it's a "cheap" rule, that's why I am more inclined to avoid merging filters in this rule, but I am happy to discuss trade-offs if you think otherwise.
author_association: MEMBER
user: login: xiedeyantu
created_at: 2025-04-24T07:09:23Z
body: > > I think it is a good design principle to have each rule be as simple as possible, and not do work that another rule can do. However, there is a balancing act in practice, because the complexity of the search process is exponential in the number of rules that can be applied at each step, so there may be performance benefits in having a rule perform optimizations that it _knows_ for sure are beneficial - rather than hoping the optimizer will guess what to do next.
> 
> I agree on your statement, I have baked into rules some "transformations" (e.g., predicate simplifications despite there is a rule to do that) when we knew we had to do it. In this case, `FILTER_MERGE` rule is part of the standard rule set in Calcite and it's a "cheap" rule, that's why I am more inclined to avoid merging filters in this rule, but I am happy to discuss trade-offs if you think otherwise.

I think you've convinced me. I just need to focus on the issues I should be dealing with.
author_association: MEMBER
user: login: asolimando
created_at: 2025-04-24T07:17:28Z
body: > Bailu Ding, Vivek Narasayya and Surajit Chaudhuri (2024), “Extensible Query Optimizers in Practice”, Foundations and Trends® in Databases: Vol. 14, No. 3-4, pp 186–402. DOI: 10.1561/1900000077

Great reference, thanks for sharing, Mihai!
author_association: MEMBER
user: login: asolimando
created_at: 2025-04-24T08:40:53Z
body: @xiedeyantu, sorry for noticing late but I think we should rephrase as "Add rule to convert Minus to Filter" or "Add rule for converting Minus to Filter", and update the Jira title/commit (and references in code) accordingly. For the commit title it can be done upon the final squashing.
author_association: MEMBER
user: login: xiedeyantu
created_at: 2025-04-24T08:45:58Z
body: > @xiedeyantu, sorry for noticing late but I think we should rephrase as "Add rule to convert Minus to Filter" or "Add rule for converting Minus to Filter", and update the Jira title/commit (and references in code) accordingly. For the commit title it can be done upon the final squashing.

@asolimando Sorry, I didn't notice that either. I'll update the Jira and PR titles soon. If someone tells me it's ready to squash, I'll then modify the commit messages.
author_association: MEMBER
user: login: mihaibudiu
created_at: 2025-04-24T16:25:27Z
body: I think this has enough approvals, can you please squash the commits?
author_association: CONTRIBUTOR
user: login: xiedeyantu
created_at: 2025-04-24T22:48:24Z
body: > I think this has enough approvals, can you please squash the commits?

Thanks! I have squashed and rebased main branch.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-04-24T23:06:32Z
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=4322) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=apache_calcite&pullRequest=4322&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=4322&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=4322&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [86.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=apache_calcite&pullRequest=4322&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=4322&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apache_calcite&pullRequest=4322)


author_association: NONE
user: login: xiedeyantu
created_at: 2025-04-24T23:31:48Z
body: This ci pr-head checker failure has nothing to do with this PR.
author_association: MEMBER