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

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


The following commit(s) were added to refs/heads/main by this push:
     new caa051d22e [CALCITE-7711] Add a rule to convert LEFT or RIGHT OUTER 
JOIN with IS NULL to ANTI JOIN
caa051d22e is described below

commit caa051d22eca673dcd1671441e6bf2e0d9bcd718
Author: zzwqqq <[email protected]>
AuthorDate: Thu Aug 13 10:24:57 2026 +0800

    [CALCITE-7711] Add a rule to convert LEFT or RIGHT OUTER JOIN with IS NULL 
to ANTI JOIN
---
 .../org/apache/calcite/rel/rules/CoreRules.java    |   5 +
 .../calcite/rel/rules/OuterJoinToAntiJoinRule.java | 222 ++++++++++++++++++
 .../calcite/test/OuterJoinToAntiJoinRuleTest.java  | 129 +++++++++++
 .../calcite/test/OuterJoinToAntiJoinRuleTest.xml   | 247 +++++++++++++++++++++
 core/src/test/resources/sql/planner.iq             |  43 ++++
 5 files changed, 646 insertions(+)

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 f827843cc6..53fe7c71fd 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
@@ -753,6 +753,11 @@ private CoreRules() {}
   public static final SemiJoinRule.JoinToSemiJoinRule JOIN_TO_SEMI_JOIN =
       
SemiJoinRule.JoinToSemiJoinRule.JoinToSemiJoinRuleConfig.DEFAULT.toRule();
 
+  /** Rule that converts an outer join followed by {@code IS NULL} on its
+   * null-generating side to an anti join. */
+  public static final OuterJoinToAntiJoinRule OUTER_JOIN_TO_ANTI_JOIN =
+      OuterJoinToAntiJoinRule.Config.DEFAULT.toRule();
+
   /** Rule that pushes a {@link Join}
    * past a non-distinct {@link Union} as its left input. */
   public static final JoinUnionTransposeRule JOIN_LEFT_UNION_TRANSPOSE =
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/OuterJoinToAntiJoinRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/OuterJoinToAntiJoinRule.java
new file mode 100644
index 0000000000..2b00563370
--- /dev/null
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/OuterJoinToAntiJoinRule.java
@@ -0,0 +1,222 @@
+/*
+ * 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.plan.Strong;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Join;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.rel.logical.LogicalFilter;
+import org.apache.calcite.rel.logical.LogicalJoin;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.tools.RelBuilder;
+import org.apache.calcite.util.ImmutableBitSet;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Planner rule that converts an outer join followed by {@code IS NULL}
+ * on its null-generating side to an anti join.
+ *
+ * <p>For example, the query
+ *
+ * <pre>{@code
+ * SELECT e.empno, d.name
+ * FROM Emp AS e
+ * LEFT JOIN Dept AS d ON e.deptno = d.deptno
+ * WHERE d.deptno IS NULL AND e.empno > 10
+ * }</pre>
+ *
+ * <p>has the following plan:
+ *
+ * <pre>{@code
+ * LogicalProject(EMPNO=[$0], NAME=[$10])
+ *   LogicalFilter(condition=[AND(IS NULL($9), >($0, 10))])
+ *     LogicalJoin(condition=[=($7, $9)], joinType=[left])
+ *       LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+ *       LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+ * }</pre>
+ *
+ * <p>The rule converts it to:
+ *
+ * <pre>{@code
+ * LogicalProject(EMPNO=[$0], NAME=[$10])
+ *   LogicalFilter(condition=[>($0, 10)])
+ *     LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3],
+ *         HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7],
+ *         SLACKER=[$8], DEPTNO0=[null:INTEGER], NAME=[null:VARCHAR(10)])
+ *       LogicalJoin(condition=[=($7, $9)], joinType=[anti])
+ *         LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+ *         LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+ * }</pre>
+ *
+ * <p>The {@code IS NULL} predicate must be a top-level conjunct over a field
+ * from the null-generating input. A field that is non-nullable in that input
+ * is safe. For a nullable field, the join condition must not be TRUE when its
+ * value is NULL.
+ */
[email protected]
+public class OuterJoinToAntiJoinRule
+    extends RelRule<OuterJoinToAntiJoinRule.Config>
+    implements TransformationRule {
+
+  /** Creates an OuterJoinToAntiJoinRule. */
+  protected OuterJoinToAntiJoinRule(Config config) {
+    super(config);
+  }
+
+  @Override public void onMatch(RelOptRuleCall call) {
+    final Filter filter = call.rel(0);
+    final Join join = call.rel(1);
+
+    // Field indexes below assume that the join has no system-field prefix.
+    if (!join.getSystemFieldList().isEmpty()) {
+      return;
+    }
+    // Rewriting may change the number and order of condition evaluations.
+    if (!RexUtil.isDeterministic(filter.getCondition())
+        || !RexUtil.isDeterministic(join.getCondition())) {
+      return;
+    }
+
+    final boolean leftJoin = join.getJoinType() == JoinRelType.LEFT;
+    // Correlated RIGHT joins are not supported because converting them 
requires
+    // swapping the inputs and remapping correlation references.
+    if (!leftJoin && !join.getVariablesSet().isEmpty()) {
+      return;
+    }
+    // Only top-level conjuncts can independently prove that a row is 
unmatched.
+    final List<RexNode> remainingConditions =
+        new ArrayList<>(RelOptUtil.conjunctions(filter.getCondition()));
+    final RexNode nullCondition =
+        findSafeNullCondition(remainingConditions, join, leftJoin);
+    if (nullCondition == null) {
+      return;
+    }
+    remainingConditions.remove(nullCondition);
+
+    final RelNode newLeft = leftJoin ? join.getLeft() : join.getRight();
+    final RelNode newRight = leftJoin ? join.getRight() : join.getLeft();
+    final RexNode condition = leftJoin
+        ? join.getCondition()
+        : JoinCommuteRule.swapJoinCond(join.getCondition(), join,
+            join.getCluster().getRexBuilder());
+    final RelBuilder builder = call.builder()
+        .push(newLeft)
+        .push(newRight)
+        .join(JoinRelType.ANTI, condition, join.getVariablesSet())
+        .hints(join.getHints());
+
+    // An anti join projects only its left input. Its rows are unmatched, so 
every
+    // field of the null-generating input is NULL. Reinsert typed NULLs to 
restore
+    // the outer join's row type.
+    final int leftCount = join.getLeft().getRowType().getFieldCount();
+    final int nullOffset = leftJoin ? leftCount : 0;
+    final List<RexNode> projects = new ArrayList<>(builder.fields());
+    insertNulls(projects, join.getRowType(), nullOffset,
+        newRight.getRowType().getFieldCount(), builder);
+
+    builder.project(projects, join.getRowType().getFieldNames())
+        .filter(filter.getVariablesSet(), remainingConditions)
+        .convert(filter.getRowType(), false);
+    call.transformTo(builder.build());
+  }
+
+  /** Returns an {@code IS NULL} condition on a null-generating input field
+   * that is non-nullable, or for which the join condition cannot be TRUE when
+   * the field is NULL; returns null if there is no such condition. */
+  private static @Nullable RexNode findSafeNullCondition(
+      List<RexNode> conditions, Join join, boolean leftJoin) {
+    final int leftCount = join.getLeft().getRowType().getFieldCount();
+    for (RexNode condition : conditions) {
+      if (!(condition instanceof RexCall)
+          || !condition.isA(SqlKind.IS_NULL)) {
+        continue;
+      }
+      final RexNode operand = ((RexCall) condition).getOperands().get(0);
+      if (!(operand instanceof RexInputRef)) {
+        continue;
+      }
+      final int index = ((RexInputRef) operand).getIndex();
+      final boolean inputOnLeft = index < leftCount;
+      if (inputOnLeft == leftJoin) {
+        continue;
+      }
+      final int inputIndex = inputOnLeft ? index : index - leftCount;
+      final RelNode input = inputOnLeft ? join.getLeft() : join.getRight();
+      final RelDataType type = input.getRowType()
+          .getFieldList().get(inputIndex).getType();
+      // If the input field is nullable, IS NULL may also be true for a matched
+      // row. It proves the row is unmatched only if the field is non-nullable,
+      // or, for a nullable field, the join condition cannot be TRUE when the
+      // field is NULL.
+      if (!type.isNullable()
+          || Strong.isNotTrue(join.getCondition(), ImmutableBitSet.of(index))) 
{
+        return condition;
+      }
+    }
+    return null;
+  }
+
+  /** Inserts typed NULL expressions for fields in the original row type. */
+  private static void insertNulls(List<RexNode> projects, RelDataType rowType,
+      int offset, int count, RelBuilder builder) {
+    final List<RexNode> nulls = new ArrayList<>(count);
+    for (int i = 0; i < count; i++) {
+      final RelDataType type =
+          rowType.getFieldList().get(offset + i).getType();
+      nulls.add(builder.getRexBuilder().makeNullLiteral(type));
+    }
+    projects.addAll(offset, nulls);
+  }
+
+  /** Rule configuration. */
+  @Value.Immutable
+  public interface Config extends RelRule.Config {
+    Config DEFAULT = ImmutableOuterJoinToAntiJoinRule.Config.of()
+        .withOperandFor(LogicalFilter.class, LogicalJoin.class);
+
+    @Override default OuterJoinToAntiJoinRule toRule() {
+      return new OuterJoinToAntiJoinRule(this);
+    }
+
+    /** Defines an operand tree for the given classes. */
+    default Config withOperandFor(Class<? extends Filter> filterClass,
+        Class<? extends Join> joinClass) {
+      return withOperandSupplier(b ->
+          b.operand(filterClass).oneInput(b2 ->
+              b2.operand(joinClass)
+                  .predicate(join -> join.getJoinType() == JoinRelType.LEFT
+                      || join.getJoinType() == JoinRelType.RIGHT)
+                  .anyInputs()))
+          .as(Config.class);
+    }
+  }
+}
diff --git 
a/core/src/test/java/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.java 
b/core/src/test/java/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.java
new file mode 100644
index 0000000000..64db040386
--- /dev/null
+++ 
b/core/src/test/java/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.test;
+
+import org.apache.calcite.rel.rules.CoreRules;
+import org.apache.calcite.rel.rules.OuterJoinToAntiJoinRule;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link OuterJoinToAntiJoinRule}.
+ *
+ * <p><a 
href="https://issues.apache.org/jira/browse/CALCITE-7711";>[CALCITE-7711]
+ * Add a rule to convert LEFT or RIGHT OUTER JOIN with IS NULL to ANTI 
JOIN</a>.
+ */
+class OuterJoinToAntiJoinRuleTest {
+
+  private static RelOptFixture fixture() {
+    return RelOptFixture.DEFAULT.withDiffRepos(
+        DiffRepository.lookup(OuterJoinToAntiJoinRuleTest.class));
+  }
+
+  private static RelOptFixture sql(String sql) {
+    return fixture().sql(sql)
+        .withRule(CoreRules.OUTER_JOIN_TO_ANTI_JOIN);
+  }
+
+  @Test void testLeftJoin() {
+    final String sql = "select e.empno, d.name\n"
+        + "from emp e left join dept d on e.deptno = d.deptno\n"
+        + "where d.deptno is null and e.empno > 10";
+    sql(sql).check();
+  }
+
+  @Test void testNullableJoinKey() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join deptnullables d on e.deptno = d.deptno\n"
+        + "where d.deptno is null";
+    sql(sql).check();
+  }
+
+  @Test void testRightJoin() {
+    final String sql = "select e.ename, d.name\n"
+        + "from emp e right join dept d on e.deptno = d.deptno\n"
+        + "where e.empno is null";
+    sql(sql).check();
+  }
+
+  @Test void testCorrelatedLeftJoin() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join dept d\n"
+        + "on e.deptno = d.deptno and exists (\n"
+        + "  select 1 from dept d2 where d2.name = d.name)\n"
+        + "where d.deptno is null";
+    sql(sql).check();
+  }
+
+  @Test void testCorrelatedRightJoin() {
+    final String sql = "select d.deptno\n"
+        + "from emp e right join dept d\n"
+        + "on e.deptno = d.deptno and exists (\n"
+        + "  select 1 from dept d2 where d2.name = d.name)\n"
+        + "where e.empno is null";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testNullableNonJoinColumn() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join deptnullables d on e.deptno = d.deptno\n"
+        + "where d.name is null";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testIsNullOnPreservedInput() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join dept d on e.deptno = d.deptno\n"
+        + "where e.comm is null";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testNullSafeJoinCondition() {
+    final String sql = "select e.empno\n"
+        + "from empnullables e left join deptnullables d\n"
+        + "on e.deptno is not distinct from d.deptno\n"
+        + "where d.deptno is null";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testIsNullInDisjunction() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join dept d on e.deptno = d.deptno\n"
+        + "where d.deptno is null or e.empno > 10";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testNonDeterministicFilter() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join dept d on e.deptno = d.deptno\n"
+        + "where d.deptno is null and rand() > 0.5";
+    sql(sql).checkUnchanged();
+  }
+
+  @Test void testNonDeterministicJoinCondition() {
+    final String sql = "select e.empno\n"
+        + "from emp e left join dept d\n"
+        + "on e.deptno = d.deptno and rand() > 0.5\n"
+        + "where d.deptno is null";
+    sql(sql).checkUnchanged();
+  }
+
+  @AfterAll static void checkActualAndReferenceFiles() {
+    fixture().diffRepos.checkActualAndReferenceFiles();
+  }
+}
diff --git 
a/core/src/test/resources/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.xml
 
b/core/src/test/resources/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.xml
new file mode 100644
index 0000000000..44be8d1fa0
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/calcite/test/OuterJoinToAntiJoinRuleTest.xml
@@ -0,0 +1,247 @@
+<?xml version="1.0" ?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to you under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<Root>
+  <TestCase name="testCorrelatedLeftJoin">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join dept d
+on e.deptno = d.deptno and exists (
+  select 1 from dept d2 where d2.name = d.name)
+where d.deptno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($9)])
+    LogicalJoin(condition=[AND(=($7, $9), EXISTS({
+LogicalFilter(condition=[=($1, $cor0.NAME)])
+  LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+}))], joinType=[left], variablesSet=[[$cor0]])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], 
SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], DEPTNO0=[null:INTEGER], 
NAME=[null:VARCHAR(10)])
+    LogicalJoin(condition=[AND(=($7, $9), EXISTS({
+LogicalFilter(condition=[=($1, $cor0.NAME)])
+  LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+}))], joinType=[anti], variablesSet=[[$cor0]])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testCorrelatedRightJoin">
+    <Resource name="sql">
+      <![CDATA[select d.deptno
+from emp e right join dept d
+on e.deptno = d.deptno and exists (
+  select 1 from dept d2 where d2.name = d.name)
+where e.empno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(DEPTNO=[$9])
+  LogicalFilter(condition=[IS NULL($0)])
+    LogicalJoin(condition=[AND(=($7, $9), EXISTS({
+LogicalFilter(condition=[=($1, $cor0.NAME)])
+  LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+}))], joinType=[right], variablesSet=[[$cor0]])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testIsNullInDisjunction">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join dept d on e.deptno = d.deptno
+where d.deptno is null or e.empno > 10]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[OR(IS NULL($9), >($0, 10))])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testIsNullOnPreservedInput">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join dept d on e.deptno = d.deptno
+where e.comm is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($6)])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testLeftJoin">
+    <Resource name="sql">
+      <![CDATA[select e.empno, d.name
+from emp e left join dept d on e.deptno = d.deptno
+where d.deptno is null and e.empno > 10]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0], NAME=[$10])
+  LogicalFilter(condition=[AND(IS NULL($9), >($0, 10))])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalProject(EMPNO=[$0], NAME=[$10])
+  LogicalFilter(condition=[>($0, 10)])
+    LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], 
SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], DEPTNO0=[null:INTEGER], 
NAME=[null:VARCHAR(10)])
+      LogicalJoin(condition=[=($7, $9)], joinType=[anti])
+        LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+        LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonDeterministicFilter">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join dept d on e.deptno = d.deptno
+where d.deptno is null and rand() > 0.5]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[AND(IS NULL($9), >(RAND(), CAST(0.5:DECIMAL(2, 
1)):DOUBLE NOT NULL))])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonDeterministicJoinCondition">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join dept d
+on e.deptno = d.deptno and rand() > 0.5
+where d.deptno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($9)])
+    LogicalJoin(condition=[AND(=($7, $9), >(RAND(), 0.5E0))], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNullSafeJoinCondition">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from empnullables e left join deptnullables d
+on e.deptno is not distinct from d.deptno
+where d.deptno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($9)])
+    LogicalJoin(condition=[IS NOT DISTINCT FROM($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMPNULLABLES]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPTNULLABLES]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNullableJoinKey">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join deptnullables d on e.deptno = d.deptno
+where d.deptno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($9)])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPTNULLABLES]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], 
SAL=[$5], COMM=[$6], DEPTNO=[$7], SLACKER=[$8], DEPTNO0=[null:INTEGER], 
NAME=[null:VARCHAR(10)])
+    LogicalJoin(condition=[=($7, $9)], joinType=[anti])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPTNULLABLES]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNullableNonJoinColumn">
+    <Resource name="sql">
+      <![CDATA[select e.empno
+from emp e left join deptnullables d on e.deptno = d.deptno
+where d.name is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[IS NULL($10)])
+    LogicalJoin(condition=[=($7, $9)], joinType=[left])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPTNULLABLES]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testRightJoin">
+    <Resource name="sql">
+      <![CDATA[select e.ename, d.name
+from emp e right join dept d on e.deptno = d.deptno
+where e.empno is null]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(ENAME=[$1], NAME=[$10])
+  LogicalFilter(condition=[IS NULL($0)])
+    LogicalJoin(condition=[=($7, $9)], joinType=[right])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalProject(ENAME=[$1], NAME=[$10])
+  LogicalProject(EMPNO=[null:INTEGER], ENAME=[null:VARCHAR(20)], 
JOB=[null:VARCHAR(10)], MGR=[null:INTEGER], HIREDATE=[null:TIMESTAMP(0)], 
SAL=[null:INTEGER], COMM=[null:INTEGER], DEPTNO=[null:INTEGER], 
SLACKER=[null:BOOLEAN], DEPTNO0=[$0], NAME=[$1])
+    LogicalJoin(condition=[=($9, $0)], joinType=[anti])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+</Root>
diff --git a/core/src/test/resources/sql/planner.iq 
b/core/src/test/resources/sql/planner.iq
index 0adbe5785b..c6f3c72623 100644
--- a/core/src/test/resources/sql/planner.iq
+++ b/core/src/test/resources/sql/planner.iq
@@ -685,4 +685,47 @@ EnumerableCalc(expr#0..3=[{inputs}], expr#4=[0], 
expr#5=[>($t2, $t4)], expr#6=[>
 !ok
 !set planner-rules original
 
+# [CALCITE-7711] Add a rule to convert LEFT or RIGHT OUTER JOIN with IS NULL 
to ANTI JOIN
+!set planner-rules "
++CoreRules.OUTER_JOIN_TO_ANTI_JOIN"
+select l.id as left_id, r.id as right_id
+from (values (1), (2), (3)) as l(id)
+left join (values (1), (3)) as r(id) on l.id = r.id
+where r.id is null
+order by l.id;
++---------+----------+
+| LEFT_ID | RIGHT_ID |
++---------+----------+
+|       2 |          |
++---------+----------+
+(1 row)
+
+!ok
+EnumerableCalc(expr#0=[{inputs}], expr#1=[null:INTEGER], proj#0..1=[{exprs}])
+  EnumerableMergeJoin(condition=[=($0, $1)], joinType=[anti])
+    EnumerableValues(tuples=[[{ 1 }, { 2 }, { 3 }]])
+    EnumerableValues(tuples=[[{ 1 }, { 3 }]])
+!plan
+
+# RIGHT JOIN keeps the non-commutative join condition after swapping inputs.
+select l.id as left_id, r.id as right_id
+from (values (1), (3), (5)) as l(id)
+right join (values (0), (4), (6)) as r(id) on l.id > r.id
+where l.id is null
+order by r.id;
++---------+----------+
+| LEFT_ID | RIGHT_ID |
++---------+----------+
+|         |        6 |
++---------+----------+
+(1 row)
+
+!ok
+EnumerableCalc(expr#0=[{inputs}], expr#1=[null:INTEGER], ID=[$t1], ID0=[$t0])
+  EnumerableNestedLoopJoin(condition=[>($1, $0)], joinType=[anti])
+    EnumerableValues(tuples=[[{ 0 }, { 4 }, { 6 }]])
+    EnumerableValues(tuples=[[{ 1 }, { 3 }, { 5 }]])
+!plan
+!set planner-rules original
+
 # End planner.iq

Reply via email to