From 5602b5ffa9b238b6672f3cc1e78aa2d6943cee36 Mon Sep 17 00:00:00 2001 From: microbluey Date: Tue, 11 Aug 2026 13:49:41 +0800 Subject: [PATCH] [CALCITE-7487] ProjectJoinTransposeRule throws ArrayIndexOutOfBoundsException in PushProjector when a Join input has a zero-column row type PushProjector.locateAllRefs contains a workaround, originally added for Fennel, that arbitrarily projects the first column of a Join or SetOp input when nothing else is projected from it. The workaround assumes the input has a first column to fall back on. That assumption does not hold for a zero-column input: a Values with an empty row type and a single empty tuple returns one row with zero columns and is the identity for cross join. It arises when an Aggregate with GROUP BY () has its output pruned to zero columns. In that case the workaround sets a bit that points past the input's fields, and createProjectRefsAndExprs later uses that bit to index into an empty field list, throwing ArrayIndexOutOfBoundsException. Guard both the left and the right workaround on the corresponding input having at least one field, so that the workaround is skipped rather than producing an out-of-range reference. Each guard tests the field count of the input it protects: nFields for the left, nFieldsRight for the right. Both directions crash, so add a regression test for each. --- .../calcite/rel/rules/PushProjector.java | 15 ++++-- .../apache/calcite/test/RelOptRulesTest.java | 50 +++++++++++++++++++ .../apache/calcite/test/RelOptRulesTest.xml | 38 ++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PushProjector.java b/core/src/main/java/org/apache/calcite/rel/rules/PushProjector.java index 20310a95c9e6..caa9b4d4dedf 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PushProjector.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PushProjector.java @@ -457,13 +457,22 @@ public boolean locateAllRefs() { || (childRel instanceof SetOp)) { // if nothing is projected from the children, arbitrarily project // the first columns; this is necessary since Fennel doesn't - // handle 0-column projections - if (nProject == 0 && childPreserveExprs.isEmpty()) { + // handle 0-column projections. + // + // An input may legitimately have a zero-column row type: for + // example, a Values with an empty row type and a single empty + // tuple, which returns one row with zero columns and is the + // identity for cross join. There is no first column to fall back + // on in that case, so skip the workaround rather than set a bit + // that points past the input's fields; createProjectRefsAndExprs + // would use it to index into an empty field list. + if (nProject == 0 && childPreserveExprs.isEmpty() && nFields > 0) { projRefs.set(0); nProject = 1; } if (childRel instanceof Join) { - if (nRightProject == 0 && rightPreserveExprs.isEmpty()) { + if (nRightProject == 0 && rightPreserveExprs.isEmpty() + && nFieldsRight > 0) { projRefs.set(nFields); nRightProject = 1; } 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 ca5cc42385f5..06ee61c26c17 100644 --- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java +++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java @@ -1559,6 +1559,56 @@ private void checkSemiOrAntiJoinProjectTranspose(JoinRelType type) { checkJoinProjectTransposeDoesNotMatch(JoinRelType.LEFT_MARK); } + /** Test case for + * [CALCITE-7487] + * ProjectJoinTransposeRule throws ArrayIndexOutOfBoundsException in + * PushProjector when a Join input has a zero-column row type. */ + @Test void testProjectJoinTransposeWithZeroColumnRightInput() { + relFn(b -> zeroColumnJoinInputRelFn(b, false)) + .withRule(CoreRules.PROJECT_JOIN_TRANSPOSE).check(); + } + + /** Test case for + * [CALCITE-7487] + * ProjectJoinTransposeRule throws ArrayIndexOutOfBoundsException in + * PushProjector when a Join input has a zero-column row type. */ + @Test void testProjectJoinTransposeWithZeroColumnLeftInput() { + relFn(b -> zeroColumnJoinInputRelFn(b, true)) + .withRule(CoreRules.PROJECT_JOIN_TRANSPOSE).check(); + } + + /** Builds {@code Project(CAST(col1))} over a cross join in which one input is + * DEE -- a {@link org.apache.calcite.rel.core.Values} with an empty row type, + * the identity for cross join. The project must be non-identity, otherwise + * {@link RelBuilder} collapses it away and the rule never fires. */ + private static RelNode zeroColumnJoinInputRelFn(RelBuilder b, + boolean deeOnLeft) { + final RelDataTypeFactory typeFactory = b.getTypeFactory(); + final RelDataType bigintType = + typeFactory.createSqlType(SqlTypeName.BIGINT); + final RelNode nonEmpty = b + .values( + ImmutableList.of( + ImmutableList.of( + (RexLiteral) b.getRexBuilder().makeZeroLiteral(bigintType))), + typeFactory.builder().add("col1", bigintType).build()) + .build(); + final RelNode dee = b + .values(ImmutableList.of(ImmutableList.of()), + typeFactory.builder().build()) + .build(); + final RelDataType varcharType = + typeFactory.createSqlType(SqlTypeName.VARCHAR); + return b + .push(deeOnLeft ? dee : nonEmpty) + .push(deeOnLeft ? nonEmpty : dee) + .join(JoinRelType.INNER, b.literal(true)) + // DEE contributes no fields, so the sole column is at index 0 + // whichever side it is on. + .project(b.getRexBuilder().makeCast(varcharType, b.field(0))) + .build(); + } + /** A SEMI, ANTI or LEFT_MARK join does not project its right input, so * {@link JoinProjectTransposeRule} must not pull projects above it. */ private void checkJoinProjectTransposeDoesNotMatch(JoinRelType type) { 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 0b544e3f1c63..49681f45a3fc 100644 --- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml +++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml @@ -12480,6 +12480,44 @@ LogicalProject(DEPTNO=[$0]) LogicalAggregate(group=[{}], DUMMY=[COUNT()]) LogicalProject(EMPNO=[$0]) LogicalTableScan(table=[[scott, EMP]]) +]]> + + + + + + + + + + + + + + + +