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 9c0634d7fc [CALCITE-7741] Outer join register incorrect types for 
their input collections
9c0634d7fc is described below

commit 9c0634d7fc09e5db21de182ca6cea3e8bda3b0e2
Author: Mihai Budiu <[email protected]>
AuthorDate: Mon Aug 24 17:42:52 2026 -0700

    [CALCITE-7741] Outer join register incorrect types for their input 
collections
    
    Signed-off-by: Mihai Budiu <[email protected]>
---
 .../org/apache/calcite/sql/validate/JoinScope.java |  17 ++-
 .../calcite/sql/validate/SqlValidatorImpl.java     |  14 ++-
 .../apache/calcite/test/SqlToRelConverterTest.java | 135 +++++++++++++++++++++
 3 files changed, 161 insertions(+), 5 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/sql/validate/JoinScope.java 
b/core/src/main/java/org/apache/calcite/sql/validate/JoinScope.java
index f03f11201c..5e64b9783c 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/JoinScope.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/JoinScope.java
@@ -43,6 +43,10 @@ public class JoinScope extends ListScope {
   private final @Nullable SqlValidatorScope usingScope;
   private final SqlJoin join;
 
+  /** Whether the side of the join currently being registered is padded with
+   * NULLs by this join. See {@link #setRegisteringNullPaddedSide}. */
+  private boolean registeringPaddedSide;
+
   //~ Constructors -----------------------------------------------------------
 
   /**
@@ -90,10 +94,21 @@ && stripAs(join.getRight()) == ns.getNode()) {
       //
       // 'a' is a child namespace of 'a join b' and also of
       // 'a join b join c'.
-      usingScope.addChild(ns, alias, nullable);
+      //
+      // The enclosing scope sees this join's output, so it also sees the 
padding
+      // this join applies.
+      usingScope.addChild(ns, alias, nullable || registeringPaddedSide);
     }
   }
 
+  /**
+   * Sets whether the side of the join being registered right now is
+   * the one this join pads with NULLs.
+   */
+  void setRegisteringNullPaddedSide(boolean nullPadded) {
+    this.registeringPaddedSide = nullPadded;
+  }
+
   @Override public @Nullable SqlWindow lookupWindow(String name) {
     // Lookup window in enclosing select.
     if (usingScope != null) {
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
index 6642a1522b..129fd91252 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
@@ -2938,8 +2938,8 @@ private SqlNode registerFrom(
       scopes.put(join, joinScope);
       final SqlNode left = join.getLeft();
       final SqlNode right = join.getRight();
-      boolean forceLeftNullable = forceNullable;
-      boolean forceRightNullable = forceNullable;
+      boolean forceLeftNullable = false;
+      boolean forceRightNullable = false;
       switch (join.getJoinType()) {
       case LEFT:
       case LEFT_ASOF:
@@ -2955,6 +2955,10 @@ private SqlNode registerFrom(
       default:
         break;
       }
+      // joinScope resolves this join's ON condition, which reads the rows its
+      // inputs produced. JoinScope adds the padding when it passes a child on 
to
+      // the enclosing scope, which does see this join's output.
+      joinScope.setRegisteringNullPaddedSide(forceLeftNullable);
       final SqlNode newLeft =
           registerFrom(
               parentScope,
@@ -2964,11 +2968,12 @@ private SqlNode registerFrom(
               left,
               null,
               null,
-              forceLeftNullable,
+              false,
               lateral);
       if (newLeft != left) {
         join.setLeft(newLeft);
       }
+      joinScope.setRegisteringNullPaddedSide(forceRightNullable);
       final SqlNode newRight =
           registerFrom(
               parentScope,
@@ -2978,11 +2983,12 @@ private SqlNode registerFrom(
               right,
               null,
               null,
-              forceRightNullable,
+              false,
               lateral);
       if (newRight != right) {
         join.setRight(newRight);
       }
+      joinScope.setRegisteringNullPaddedSide(false);
       scopes.putIfAbsent(stripAs(join.getRight()), parentScope);
       scopes.putIfAbsent(stripAs(join.getLeft()), parentScope);
       registerSubQueries(joinScope, join.getCondition());
diff --git 
a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
index d56b7d5437..fe86066be3 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
@@ -45,7 +45,11 @@
 import org.apache.calcite.rel.logical.LogicalTableModify;
 import org.apache.calcite.rel.rules.CoreRules;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexCallBinding;
 import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexShuttle;
 import org.apache.calcite.sql.SqlExplainLevel;
 import org.apache.calcite.sql.fun.SqlLibrary;
 import org.apache.calcite.sql.type.SqlTypeName;
@@ -366,6 +370,137 @@ public static void checkActualAndReferenceFiles() {
     sql(sql).ok();
   }
 
+  /** Checks that every call in the plan for {@code sql} has the type that its
+   * operator derives from its operands. */
+  private void checkCallTypesConsistent(String sql) {
+    final RelNode rel = sql(sql).toRel();
+    final RexBuilder rexBuilder = rel.getCluster().getRexBuilder();
+    final RexShuttle shuttle = new RexShuttle() {
+      @Override public RexNode visitCall(RexCall call) {
+        final RelDataType derived =
+            call.getOperator().inferReturnType(
+                RexCallBinding.create(rexBuilder.getTypeFactory(), call, 
ImmutableList.of()));
+        assertThat("type of " + call,
+            call.getType().getFullTypeString(), 
is(derived.getFullTypeString()));
+        return super.visitCall(call);
+      }
+    };
+    new RelHomogeneousShuttle() {
+      @Override public RelNode visit(RelNode other) {
+        other.accept(shuttle);
+        return super.visit(other);
+      }
+    }.visit(rel);
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>A LEFT JOIN pads with NULLs the right input. */
+  @Test void testJoinConditionTypeLeft() {
+    checkCallTypesConsistent("select e.ename, d.name from emp as e\n"
+        + "left join dept as d on e.ename = trim(d.name)");
+    checkCallTypesConsistent("select e.ename, d.name from emp as e\n"
+        + "left join dept as d on trim(e.ename) = trim(d.name)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>An ON condition that reads only the NULL-padded side. */
+  @Test void testJoinConditionTypeOnePaddedSideOnly() {
+    checkCallTypesConsistent("select e.ename, d.name from emp as e\n"
+        + "left join dept as d on trim(d.name) = cast(d.deptno as 
varchar(10))");
+    checkCallTypesConsistent("select e.ename, d.name from emp as e\n"
+        + "left join dept as d on trim(d.name) = 'x'");
+    checkCallTypesConsistent("select e.ename, d.name from dept as d\n"
+        + "right join emp as e on trim(d.name) = 'x'");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>A RIGHT JOIN, whose output NULL pads the left input. */
+  @Test void testJoinConditionTypeRight() {
+    checkCallTypesConsistent("select e.ename, d.name from dept as d\n"
+        + "right join emp as e on e.ename = trim(d.name)");
+    checkCallTypesConsistent("select e.ename, d.name from dept as d\n"
+        + "right join emp as e on trim(d.name) = trim(e.ename)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>A FULL JOIN, whose output NULL pads both inputs. */
+  @Test void testJoinConditionTypeFull() {
+    checkCallTypesConsistent("select e.ename, d.name from emp as e\n"
+        + "full join dept as d on trim(e.ename) = trim(d.name)");
+    checkCallTypesConsistent("select e.ename, d.name from dept as d\n"
+        + "full join emp as e on trim(d.name) = trim(e.ename)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>An ON condition over a column that a nested outer join
+   * has already padded: there the column really is nullable, so the condition
+   * must see it as nullable. */
+  @Test void testJoinConditionTypeNestedOuter() {
+    checkCallTypesConsistent("select e.ename from emp as e\n"
+        + "left join (dept as d left join emp as e2\n"
+        + "    on trim(d.name) = trim(e2.ename))\n"
+        + "  on trim(e.ename) = trim(e2.ename)");
+    checkCallTypesConsistent("select e.ename from\n"
+        + "(emp as e2 right join dept as d\n"
+        + "    on trim(e2.ename) = trim(d.name))\n"
+        + "  right join emp as e on trim(e2.ename) = trim(e.ename)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>An inner join nested inside the padded side of an outer
+   * join: the padding the outer join applies must not affect the condition of 
the inner join. */
+  @Test void testJoinConditionTypeInnerUnderOuter() {
+    checkCallTypesConsistent("select e.ename from emp as e\n"
+        + "left join (emp as e2 join dept as d on trim(e2.ename) = 
trim(d.name))\n"
+        + "  on trim(e.ename) = trim(d.name)");
+    checkCallTypesConsistent("select e.ename from\n"
+        + "(emp as e2 join dept as d on trim(e2.ename) = trim(d.name))\n"
+        + "  right join emp as e on trim(e.ename) = trim(d.name)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>A join condition over an expression other than TRIM, and
+   * for an INNER JOIN, which pads nothing. */
+  @Test void testJoinConditionTypeOtherExpressions() {
+    checkCallTypesConsistent("select e.ename from emp as e\n"
+        + "left join dept as d on upper(e.ename) = upper(d.name)");
+    checkCallTypesConsistent("select e.ename from emp as e\n"
+        + "left join dept as d on char_length(e.ename) = char_length(d.name)");
+    checkCallTypesConsistent("select e.ename from emp as e\n"
+        + "join dept as d on trim(e.ename) = trim(d.name)");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7741";>[CALCITE-7741]
+   * Outer join register incorrect types for their input collections</a>.
+   *
+   * <p>A LATERAL table on the padded side of an outer join. */
+  @Test void testJoinConditionTypeLateral() {
+    checkCallTypesConsistent("select * from dept as d\n"
+        + "left join lateral (select * from emp as e where e.deptno = 
d.deptno) as l on true");
+  }
+
   @Test void testJoinOnIn() {
     final String sql = "select * from emp join dept\n"
         + " on emp.deptno = dept.deptno and emp.empno in (1, 3)";

Reply via email to