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)";