This is an automated email from the ASF dual-hosted git repository. mihaibudiu pushed a commit to branch issue7911 in repository https://gitbox.apache.org/repos/asf/calcite.git
commit d66099009a34b5d678515758ad8367206be0966e Author: Mihai Budiu <[email protected]> AuthorDate: Sat Oct 31 15:56:59 2026 -0700 [CALCITE-7911] FROM a, b JOIN c is parsed incorrectly Signed-off-by: Mihai Budiu <[email protected]> --- core/src/main/codegen/templates/Parser.jj | 42 ++++++----- .../main/java/org/apache/calcite/sql/SqlJoin.java | 2 +- .../calcite/sql/validate/SqlValidatorImpl.java | 13 +++- .../apache/calcite/sql2rel/RelDecorrelator.java | 36 +++++++-- .../apache/calcite/sql2rel/SqlToRelConverter.java | 69 ++++++++++++++++-- .../calcite/rel/rel2sql/RelToSqlConverterTest.java | 6 +- .../calcite/sql2rel/RelDecorrelatorTest.java | 34 +++++---- .../org/apache/calcite/test/SqlValidatorTest.java | 26 ++++++- .../apache/calcite/test/SqlToRelConverterTest.xml | 36 +++++---- core/src/test/resources/sql/join.iq | 85 ++++++++++++++++++++++ core/src/test/resources/sql/lateral.iq | 76 ++++++++++++++++++- site/_docs/reference.md | 6 ++ .../apache/calcite/sql/parser/SqlParserTest.java | 71 ++++++++++++++---- 13 files changed, 419 insertions(+), 83 deletions(-) diff --git a/core/src/main/codegen/templates/Parser.jj b/core/src/main/codegen/templates/Parser.jj index 6317b9c088..341920fd86 100644 --- a/core/src/main/codegen/templates/Parser.jj +++ b/core/src/main/codegen/templates/Parser.jj @@ -2309,39 +2309,43 @@ SqlNode FromClause() : SqlLiteral joinType; } { - e = TableRef1(ExprContext.ACCEPT_QUERY_OR_JOIN) + e = Join() ( // Comma joins should only occur at top-level in the FROM clause. + // JOIN binds tighter than comma, as required by the SQL standard: + // "FROM a, b JOIN c" means "FROM a, (b JOIN c)". // Valid: // * FROM a, b // * FROM (a CROSS JOIN b), c // Not valid: // * FROM a CROSS JOIN (b, c) - LOOKAHEAD(1) - e = JoinOrCommaTable(e) + LOOKAHEAD(2) + <COMMA> { joinType = JoinType.COMMA.symbol(getPos()); } + e2 = Join() { + e = new SqlJoin(joinType.getParserPosition(), + e, + SqlLiteral.createBoolean(false, joinType.getParserPosition()), + joinType, + e2, + JoinConditionType.NONE.symbol(SqlParserPos.ZERO), + null); + } )* { return e; } } -SqlNode JoinOrCommaTable(SqlNode e) : +/** Parses a table reference followed by zero or more joins. */ +SqlNode Join() : { - SqlNode e2; - SqlLiteral joinType; + SqlNode e; } { - LOOKAHEAD(2) - <COMMA> { joinType = JoinType.COMMA.symbol(getPos()); } - e2 = TableRef1(ExprContext.ACCEPT_QUERY_OR_JOIN) { - return new SqlJoin(joinType.getParserPosition(), - e, - SqlLiteral.createBoolean(false, joinType.getParserPosition()), - joinType, - e2, - JoinConditionType.NONE.symbol(SqlParserPos.ZERO), - null); - } -| - e2 = JoinTable(e) { return e2; } + e = TableRef1(ExprContext.ACCEPT_QUERY_OR_JOIN) + ( + LOOKAHEAD(2) + e = JoinTable(e) + )* + { return e; } } /** Matches "LEFT JOIN t ON ...", "RIGHT JOIN t USING ...", "JOIN t". */ diff --git a/core/src/main/java/org/apache/calcite/sql/SqlJoin.java b/core/src/main/java/org/apache/calcite/sql/SqlJoin.java index f948a7d668..7cb62854a5 100644 --- a/core/src/main/java/org/apache/calcite/sql/SqlJoin.java +++ b/core/src/main/java/org/apache/calcite/sql/SqlJoin.java @@ -36,7 +36,7 @@ */ public class SqlJoin extends SqlCall { static final SqlJoinOperator COMMA_OPERATOR = - new SqlJoinOperator("COMMA-JOIN", 18); + new SqlJoinOperator("COMMA-JOIN", 16); public static final SqlJoinOperator OPERATOR = new SqlJoinOperator("JOIN", 18); 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 837d90b1d2..bfd8ee0970 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 @@ -2996,8 +2996,17 @@ private SqlNode registerFrom( } final SqlNode node2 = s != null ? s.getNode() : node; final TableScope tableScope = new TableScope(parentScope0, node2); - if (usingScope instanceof ListScope) { - for (ScopeChild child : ((ListScope) usingScope).children) { + // The items to the left of this one are visible, also those before an + // enclosing join, as "a" in "FROM a, LATERAL (...) CROSS JOIN b". + // JoinScope propagates each child to the enclosing JoinScope, so the + // outermost one holds all of them, in FROM-list order. + SqlValidatorScope outer = usingScope; + while (outer instanceof JoinScope + && ((JoinScope) outer).getUsingScope() instanceof JoinScope) { + outer = ((JoinScope) outer).getUsingScope(); + } + if (outer instanceof ListScope) { + for (ScopeChild child : ((ListScope) outer).children) { tableScope.addChild(child.namespace, child.name, child.nullable); } } diff --git a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java index 585b9d0196..0701e592ff 100644 --- a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java +++ b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java @@ -1920,6 +1920,26 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { assert rel.getRequiredColumns().cardinality() <= rightFrame.corDefOutputs.keySet().size(); + // In a LEFT correlate, the right input gives the values of the variables of + // enclosing correlates, and they are NULL in the rows that the join pads. + // Unless the parent supplies these NULLs again, get the values from the + // left input too; the conditions below match the copies. + Frame newLeftFrame = leftFrame; + if (rel.getJoinType().generatesNullsOnRight() + && !parentPropagatesNullValues) { + final List<CorRef> outerCorVars = new ArrayList<>(); + for (CorRef corRef : collectExternalCorVars(oldRight)) { + if (!corRef.corr.equals(rel.getCorrelationId())) { + outerCorVars.add(corRef); + } + } + if (!outerCorVars.isEmpty()) { + newLeftFrame = + supplyMissingCorVars(oldLeft, leftFrame, outerCorVars, + new TreeMap<>()); + } + } + // Change correlator rel into a join. // Join all the correlated variables produced by this correlator rel // with the values generated and propagated from the right input @@ -1927,7 +1947,7 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { new TreeMap<>(rightFrame.corDefOutputs); final List<RexNode> conditions = new ArrayList<>(); final List<RelDataTypeField> newLeftOutput = - leftFrame.r.getRowType().getFieldList(); + newLeftFrame.r.getRowType().getFieldList(); int newLeftFieldCount = newLeftOutput.size(); final List<RelDataTypeField> newRightOutput = @@ -1939,7 +1959,7 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { if (!corDef.corr.equals(rel.getCorrelationId())) { continue; } - final int newLeftPos = requireNonNull(leftFrame.oldToNewOutputs.get(corDef.field)); + final int newLeftPos = requireNonNull(newLeftFrame.oldToNewOutputs.get(corDef.field)); final int newRightPos = rightOutput.getValue(); // Using `equals` instead of `IS NOT DISTINCT FROM` is an optimization @@ -1960,6 +1980,12 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { corDefOutputs.remove(corDef); } + // If both inputs propagate the same reference to an enclosing correlate, + // the two copies must have the same value. + conditions.addAll( + buildCorDefJoinConditions(newLeftFrame.corDefOutputs, corDefOutputs, + newLeftFrame.r, rightFrame.r, relBuilder)); + // Update the output position for the corVars: only pass on the cor // vars that are not used in the join key. for (Map.Entry<CorDef, Integer> entry : corDefOutputs.entrySet()) { @@ -1968,7 +1994,7 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { // then add any corVar from the left input. Do not need to change // output positions. - corDefOutputs.putAll(leftFrame.corDefOutputs); + corDefOutputs.putAll(newLeftFrame.corDefOutputs); // Create the mapping between the output of the old correlation rel // and the new join rel @@ -1982,7 +2008,7 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { == oldLeftFieldCount + oldRightFieldCount; // Left input positions are not changed. - mapOldToNewOutputs.putAll(leftFrame.oldToNewOutputs); + mapOldToNewOutputs.putAll(newLeftFrame.oldToNewOutputs); // Right input positions are shifted by newLeftFieldCount. for (int i = 0; i < oldRightFieldCount; i++) { @@ -1992,7 +2018,7 @@ private static boolean isWidening(RelDataType type, RelDataType type1) { final RexNode condition = RexUtil.composeConjunction(relBuilder.getRexBuilder(), conditions); - RelNode newJoin = relBuilder.push(leftFrame.r).push(rightFrame.r) + RelNode newJoin = relBuilder.push(newLeftFrame.r).push(rightFrame.r) .join(rel.getJoinType(), condition).build(); return register(rel, newJoin, mapOldToNewOutputs, corDefOutputs); diff --git a/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java b/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java index 49953ced1f..e9fce1f55f 100644 --- a/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java +++ b/core/src/main/java/org/apache/calcite/sql2rel/SqlToRelConverter.java @@ -161,6 +161,7 @@ import org.apache.calcite.sql.validate.AggregatingSelectScope; import org.apache.calcite.sql.validate.CollectNamespace; import org.apache.calcite.sql.validate.DelegatingScope; +import org.apache.calcite.sql.validate.JoinScope; import org.apache.calcite.sql.validate.ListScope; import org.apache.calcite.sql.validate.MatchRecognizeScope; import org.apache.calcite.sql.validate.MeasureScope; @@ -3505,6 +3506,22 @@ protected RelNode createAsofJoin( return node; } + /** Returns the number of fields before a namespace in the FROM list of a + * scope, or 0 if the namespace is not in the list. */ + private static int fieldOffset(SqlValidatorScope scope, + SqlValidatorNamespace namespace) { + int offset = 0; + if (scope instanceof ListScope) { + for (SqlValidatorNamespace child : ((ListScope) scope).getChildren()) { + if (child == namespace) { + return offset; + } + offset += child.getRowType().getFieldCount(); + } + } + return 0; + } + private @Nullable CorrelationUse getCorrelationUse(Blackboard bb, final RelNode r0) { final Set<CorrelationId> correlatedVariables = RelOptUtil.getVariablesUsed(r0); @@ -3513,8 +3530,13 @@ protected RelNode createAsofJoin( } final ImmutableBitSet.Builder requiredColumns = ImmutableBitSet.builder(); final List<CorrelationId> correlNames = new ArrayList<>(); - // Mapping from (correlId, originalFieldIndex) to projectedFieldIndex for aggregation + // Mapping from (correlId, originalFieldIndex) to the new field index: the + // projected index for aggregation, or the index relative to the join inputs final Map<Pair<CorrelationId, Integer>, Integer> fieldMapping = new HashMap<>(); + // Offset in the FROM list of the first input of the join that defines the + // variable, and the row type of the variable, which spans the FROM list + int offset = 0; + @Nullable RelDataType correlRowType = null; for (CorrelationId correlName : correlatedVariables) { DeferredLookup lookup = @@ -3540,6 +3562,18 @@ protected RelNode createAsofJoin( continue; } + if (bb.scope instanceof JoinScope) { + // The variable belongs to the join whose inputs contain the item it + // references. These inputs need not start the FROM list, as + // "b CROSS JOIN LATERAL (... b.x ...)" in "FROM a, b CROSS JOIN ...". + final List<SqlValidatorNamespace> joinChildren = + ((JoinScope) bb.scope).getChildren(); + if (joinChildren.stream().noneMatch(ns -> ns == resolve.namespace)) { + continue; + } + offset = fieldOffset(ancestorScope, joinChildren.get(0)); + } + int namespaceOffset = 0; if (childNamespaceIndex > 0) { // If not the first child, need to figure out the width @@ -3585,10 +3619,11 @@ protected RelNode createAsofJoin( throw new AssertionError("Identifier '" + lookup.originalRelName + "." + originalFieldName + "' is not a group expr"); } - } + } // end for correlName - requiredColumns.set(pos); + requiredColumns.set(pos - offset); correlNames.add(correlName); + correlRowType = topLevelFieldAccess.getReferenceExpr().getType(); } if (correlNames.isEmpty()) { @@ -3607,13 +3642,28 @@ protected RelNode createAsofJoin( leaves.put(r, r.getRowType().getFieldCount()); } - // If there are field mappings (due to aggregation), rewrite the RelNode tree - // to update correlation variable row type and field indices + // A join scope never has an aggregate root + assert fieldMapping.isEmpty() || offset == 0 + : "aggregate root in a join scope"; + @Nullable RelDataType newCorrelRowType = null; if (!fieldMapping.isEmpty()) { + newCorrelRowType = bb.root().getRowType(); + } else if (offset > 0) { + final List<RelDataTypeField> fields = + requireNonNull(correlRowType, "correlRowType").getFieldList(); + for (int i = offset; i < fields.size(); i++) { + fieldMapping.put(Pair.of(correlNames.get(0), i), i - offset); + } + newCorrelRowType = + typeFactory.createStructType(fields.subList(offset, fields.size())); + } + if (newCorrelRowType != null) { + // Rewrite the RelNode tree to update the row type and field indices of + // the correlation variable r = r.accept( new CorrelationFieldMappingShuttle(rexBuilder, correlNames.get(0), - bb.root().getRowType(), fieldMapping)); + newCorrelRowType, fieldMapping)); } return new CorrelationUse(correlNames.get(0), requiredColumns.build(), r); @@ -6631,6 +6681,13 @@ private static class CorrelationFieldMappingShuttle extends RelHomogeneousShuttl } return super.visitFieldAccess(fieldAccess); } + + @Override public RexNode visitSubQuery(RexSubQuery subQuery) { + final RelNode r = + subQuery.rel.accept(CorrelationFieldMappingShuttle.this); + return super.visitSubQuery( + r == subQuery.rel ? subQuery : subQuery.clone(r)); + } }); } } diff --git a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java index 7d716d0947..bbaf33dd16 100644 --- a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java +++ b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java @@ -662,13 +662,13 @@ private static RelNode sqlToRel(String sql, SchemaPlus defaultSchema, + "match_condition \"employee\".\"department_id\" <= \"product\".\"product_id\"\n" + "on \"employee\".\"salary\" = \"product\".\"gross_weight\""; final String expected = "SELECT \"t\".\"full_name\"\nFROM " - + "((SELECT \"employee_id\", \"full_name\", \"first_name\", \"last_name\", \"position_id\", " + + "(SELECT \"employee_id\", \"full_name\", \"first_name\", \"last_name\", \"position_id\", " + "\"position_title\", \"store_id\", \"department_id\", \"birth_date\", \"hire_date\", " + "\"end_date\", \"salary\", \"supervisor_id\", \"education_level\", \"marital_status\", " + "\"gender\", \"management_role\", CAST(\"salary\" AS DOUBLE) AS \"salary0\"\n" - + "FROM \"foodmart\".\"employee\") AS \"t\" ASOF JOIN \"foodmart\".\"product\" " + + "FROM \"foodmart\".\"employee\") AS \"t\"\nASOF JOIN \"foodmart\".\"product\" " + "MATCH_CONDITION \"t\".\"department_id\" <= \"product\".\"product_id\" ON " - + "\"t\".\"salary0\" = \"product\".\"gross_weight\")"; + + "\"t\".\"salary0\" = \"product\".\"gross_weight\""; sql(sql).ok(expected); } diff --git a/core/src/test/java/org/apache/calcite/sql2rel/RelDecorrelatorTest.java b/core/src/test/java/org/apache/calcite/sql2rel/RelDecorrelatorTest.java index 2757a7a49b..d8f1fb54b2 100644 --- a/core/src/test/java/org/apache/calcite/sql2rel/RelDecorrelatorTest.java +++ b/core/src/test/java/org/apache/calcite/sql2rel/RelDecorrelatorTest.java @@ -287,13 +287,15 @@ public static Frameworks.ConfigBuilder config() { final String planAfter = "" + "LogicalSort(sort0=[$0], dir0=[ASC])\n" + " LogicalProject(ENAME_FROM_DEPT=[$2])\n" - + " LogicalJoin(condition=[IS NOT DISTINCT FROM($0, $1)], joinType=[left])\n" + + " LogicalJoin(condition=[=($0, $1)], joinType=[left])\n" + " LogicalValues(tuples=[[{ 7369 }, { 7499 }]])\n" + " LogicalAggregate(group=[{0}], agg#0=[SINGLE_VALUE($1)])\n" - + " LogicalProject(EMPNO1=[$11], EXPR$0=[||(||($1, ' from dept '), $12)])\n" - + " LogicalJoin(condition=[AND(=($7, $9), =($8, $10))], joinType=[left])\n" - + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], EMPNO0=[CAST($0):INTEGER NOT NULL])\n" - + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalProject(EMPNO1=[$9], EXPR$0=[||(||($1, ' from dept '), $13)])\n" + + " LogicalJoin(condition=[AND(=($7, $10), =($8, $11), IS NOT DISTINCT FROM($9, $12))], joinType=[left])\n" + + " LogicalJoin(condition=[true], joinType=[inner])\n" + + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], EMPNO0=[CAST($0):INTEGER NOT NULL])\n" + + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalValues(tuples=[[{ 7369 }, { 7499 }]])\n" + " LogicalAggregate(group=[{0, 1, 2}], agg#0=[SINGLE_VALUE($3)])\n" + " LogicalProject(DEPTNO0=[$3], EMPNO0=[$4], EMPNO=[$5], DNAME=[$1])\n" + " LogicalJoin(condition=[=($0, $3)], joinType=[inner])\n" @@ -910,9 +912,12 @@ public static Frameworks.ConfigBuilder config() { + " LogicalProject(DEPTNO0=[$0], $f1=[true])\n" + " LogicalAggregate(group=[{0}])\n" + " LogicalProject(DEPTNO0=[$8])\n" - + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], DEPTNO0=[CAST($8):TINYINT], ENAME0=[$9], EXPR$0=[CAST($10):BIGINT])\n" - + " LogicalJoin(condition=[AND(IS NOT DISTINCT FROM($1, $9), =($7, $8))], joinType=[inner])\n" - + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], DEPTNO0=[$8], DEPTNO1=[CAST($9):TINYINT], ENAME0=[$10], EXPR$0=[CAST($11):BIGINT])\n" + + " LogicalJoin(condition=[AND(IS NOT DISTINCT FROM($1, $10), IS NOT DISTINCT FROM($8, $9))], joinType=[inner])\n" + + " LogicalJoin(condition=[=($7, $8)], joinType=[inner])\n" + + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalProject(DEPTNO=[$0])\n" + + " LogicalTableScan(table=[[scott, DEPT]])\n" + " LogicalFilter(condition=[>($2, 5)])\n" + " LogicalAggregate(group=[{0, 1}], EXPR$0=[SUM($2)])\n" + " LogicalProject(DEPTNO=[$0], ENAME=[$1], X=[$2])\n" @@ -1025,14 +1030,17 @@ public static Frameworks.ConfigBuilder config() { // Verify plan final String planAfter = "" + "LogicalProject(DNAME=[$1])\n" - + " LogicalJoin(condition=[IS NOT DISTINCT FROM($0, $3)], joinType=[inner])\n" + + " LogicalJoin(condition=[=($0, $3)], joinType=[inner])\n" + " LogicalTableScan(table=[[scott, DEPT]])\n" + " LogicalProject(DEPTNO0=[$0], $f1=[true])\n" + " LogicalAggregate(group=[{0}])\n" - + " LogicalProject(DEPTNO0=[$9])\n" - + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], ENAME0=[$8], DEPTNO0=[CAST($9):TINYINT], EXPR$0=[CAST($10):BIGINT])\n" - + " LogicalJoin(condition=[IS NOT DISTINCT FROM($1, $8)], joinType=[inner])\n" - + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalProject(DEPTNO0=[$8])\n" + + " LogicalProject(EMPNO=[$0], ENAME=[$1], JOB=[$2], MGR=[$3], HIREDATE=[$4], SAL=[$5], COMM=[$6], DEPTNO=[$7], DEPTNO0=[$8], ENAME0=[$9], DEPTNO1=[CAST($10):TINYINT], EXPR$0=[CAST($11):BIGINT])\n" + + " LogicalJoin(condition=[AND(IS NOT DISTINCT FROM($1, $9), IS NOT DISTINCT FROM($8, $10))], joinType=[inner])\n" + + " LogicalJoin(condition=[true], joinType=[inner])\n" + + " LogicalTableScan(table=[[scott, EMP]])\n" + + " LogicalProject(DEPTNO=[$0])\n" + + " LogicalTableScan(table=[[scott, DEPT]])\n" + " LogicalFilter(condition=[>($2, 5)])\n" + " LogicalAggregate(group=[{0, 1}], EXPR$0=[SUM($2)])\n" + " LogicalProject(ENAME=[$0], DEPTNO=[$1], X=[$2])\n" diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java index ba5d9b02de..db035bbfb6 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -10765,9 +10765,8 @@ void testOrderByAliasPreservesValidatedExpressions(String query) { * <a href="https://issues.apache.org/jira/browse/CALCITE-6266">[CALCITE-6266] * SqlValidatorException with LATERAL TABLE and JOIN</a>. */ @Test void testCollectionTableWithLateral3() { - // The cause of [CALCITE-6266] was that CROSS JOIN had higher precedence - // than ",", and therefore "table(ramp(deptno)" was being associated with - // "values". + // CROSS JOIN binds tighter than ",", so "table(ramp(deptno))" is joined + // with "values"; LATERAL still sees "dept", which is before the comma. sql("select *\n" + "from dept,\n" + " lateral table(ramp(deptno))\n" @@ -10791,6 +10790,27 @@ void testOrderByAliasPreservesValidatedExpressions(String query) { + " lateral (select * from dept where dept.deptno = emp.deptno),\n" + " ^emp^") .fails("Duplicate relation name 'EMP' in FROM clause"); + // LATERAL on the right of a join sees the items on the left of the join + // and the items before the comma + sql("select *\n" + + "from dept,\n" + + " emp cross join lateral (select * from bonus\n" + + " where bonus.ename = emp.ename and bonus.job = dept.name)") + .ok(); + } + + /** Test case for + * <a href="https://issues.apache.org/jira/browse/CALCITE-7911">[CALCITE-7911] + * FROM a, b JOIN c is parsed incorrectly</a>. */ + @Test void testJoinOnAfterComma() { + // "emp, dept join bonus on c" means "emp, (dept join bonus on c)", + // so c sees dept and bonus, but not emp + sql("select *\n" + + "from emp, dept join bonus on dept.name = bonus.job") + .ok(); + sql("select *\n" + + "from emp, dept join bonus on ^emp^.ename = bonus.ename") + .fails("Table 'EMP' not found"); } /** Test case for diff --git a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml index 22aeace682..88bde935de 100644 --- a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml +++ b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml @@ -6576,17 +6576,21 @@ from (select 2+deptno d2, 3+deptno d3 from emp) e <Resource name="plan"> <![CDATA[ LogicalProject(D2=[$0], D3=[$1]) - LogicalProject(D2=[$0], D3=[$1], D1=[CAST($2):INTEGER], D6=[$3], $f2=[CAST($4):BOOLEAN]) - LogicalJoin(condition=[AND(=($0, $2), IS NOT DISTINCT FROM($1, $3))], joinType=[inner]) + LogicalProject(D2=[$0], D3=[$1], D1=[CAST($2):INTEGER], D30=[CAST($3):INTEGER], $f2=[CAST($4):BOOLEAN]) + LogicalJoin(condition=[AND(=($0, $2), =($1, $3))], joinType=[inner]) LogicalProject(D2=[+(2, $7)], D3=[+(3, $7)]) LogicalTableScan(table=[[CATALOG, SALES, EMP]]) LogicalAggregate(group=[{0, 1}], agg#0=[MIN($2)]) - LogicalProject(D1=[$0], D6=[$2], $f0=[true]) + LogicalProject(D1=[$0], D3=[$2], $f0=[true]) LogicalFilter(condition=[IS NOT NULL($1)]) - LogicalProject(D1=[$0], $f0=[$3], D6=[$2]) - LogicalJoin(condition=[=($0, $1)], joinType=[left]) - LogicalProject(D1=[+($0, 1)]) - LogicalTableScan(table=[[CATALOG, SALES, DEPT]]) + LogicalProject(D1=[$0], $f0=[$4], D3=[$1]) + LogicalJoin(condition=[AND(=($0, $2), IS NOT DISTINCT FROM($1, $3))], joinType=[left]) + LogicalJoin(condition=[true], joinType=[inner]) + LogicalProject(D1=[+($0, 1)]) + LogicalTableScan(table=[[CATALOG, SALES, DEPT]]) + LogicalAggregate(group=[{0}]) + LogicalProject(D3=[+(3, $7)]) + LogicalTableScan(table=[[CATALOG, SALES, EMP]]) LogicalAggregate(group=[{0, 1}], agg#0=[MIN($2)]) LogicalProject(D4=[$0], D6=[$2], $f0=[true]) LogicalFilter(condition=[=($1, $0)]) @@ -6606,17 +6610,21 @@ from (select 2+deptno d2, 3+deptno d3 from emp) e <Resource name="plan"> <![CDATA[ LogicalProject(D2=[$0], D3=[$1]) - LogicalProject(D2=[$0], D3=[$1], D1=[CAST($2):INTEGER], D6=[$3], $f2=[CAST($4):BOOLEAN]) - LogicalJoin(condition=[AND(=($0, $2), IS NOT DISTINCT FROM($1, $3))], joinType=[inner]) + LogicalProject(D2=[$0], D3=[$1], D1=[CAST($2):INTEGER], D30=[CAST($3):INTEGER], $f2=[CAST($4):BOOLEAN]) + LogicalJoin(condition=[AND(=($0, $2), =($1, $3))], joinType=[inner]) LogicalProject(D2=[+(2, $7)], D3=[+(3, $7)]) LogicalTableScan(table=[[CATALOG, SALES, EMP]]) LogicalAggregate(group=[{0, 1}], agg#0=[MIN($2)]) - LogicalProject(D1=[$0], D6=[$2], $f0=[true]) + LogicalProject(D1=[$0], D3=[$2], $f0=[true]) LogicalFilter(condition=[IS NOT NULL($1)]) - LogicalProject(D1=[$0], $f0=[$3], D6=[$2]) - LogicalJoin(condition=[=($0, $1)], joinType=[left]) - LogicalProject(D1=[+($0, 1)]) - LogicalTableScan(table=[[CATALOG, SALES, DEPT]]) + LogicalProject(D1=[$0], $f0=[$4], D3=[$1]) + LogicalJoin(condition=[AND(=($0, $2), IS NOT DISTINCT FROM($1, $3))], joinType=[left]) + LogicalJoin(condition=[true], joinType=[inner]) + LogicalProject(D1=[+($0, 1)]) + LogicalTableScan(table=[[CATALOG, SALES, DEPT]]) + LogicalAggregate(group=[{0}]) + LogicalProject(D3=[+(3, $7)]) + LogicalTableScan(table=[[CATALOG, SALES, EMP]]) LogicalAggregate(group=[{0, 1}], agg#0=[MIN($2)]) LogicalProject(D4=[$0], D6=[$2], $f0=[true]) LogicalFilter(condition=[=($1, $0)]) diff --git a/core/src/test/resources/sql/join.iq b/core/src/test/resources/sql/join.iq index bac15e2a08..de770cced9 100644 --- a/core/src/test/resources/sql/join.iq +++ b/core/src/test/resources/sql/join.iq @@ -91,6 +91,91 @@ where t1.i + t2.i + t3.i + t4.i = 1; !ok +# [CALCITE-7911] FROM a, b JOIN c is parsed incorrectly +# JOIN binds tighter than comma: s CROSS JOIN (t RIGHT JOIN u ON t.x = u.y) +# Same result on PostgreSQL 14. +with s (c0) as (values 'a', 'b'), + t (x) as (values 1, 2), + u (y) as (values 10, 20) +select s.c0, t.x, u.y +from s, t right join u on t.x = u.y; ++----+---+----+ +| C0 | X | Y | ++----+---+----+ +| a | | 10 | +| a | | 20 | +| b | | 10 | +| b | | 20 | ++----+---+----+ +(4 rows) + +!ok + +# s CROSS JOIN (u LEFT JOIN t ON t.x = u.y) +# Same result on PostgreSQL 14. +with s (c0) as (values 'a', 'b'), + t (x) as (values 1, 2), + u (y) as (values 10, 20) +select s.c0, t.x, u.y +from s, u left join t on t.x = u.y; ++----+---+----+ +| C0 | X | Y | ++----+---+----+ +| a | | 10 | +| a | | 20 | +| b | | 10 | +| b | | 20 | ++----+---+----+ +(4 rows) + +!ok + +# s CROSS JOIN (t FULL JOIN u ON t.x = u.y) +# Same result on PostgreSQL 14. +with s (c0) as (values 'a', 'b'), + t (x) as (values 1, 2), + u (y) as (values 10, 20) +select s.c0, t.x, u.y +from s, t full join u on t.x = u.y; ++----+---+----+ +| C0 | X | Y | ++----+---+----+ +| a | 1 | | +| a | 2 | | +| a | | 10 | +| a | | 20 | +| b | 1 | | +| b | 2 | | +| b | | 10 | +| b | | 20 | ++----+---+----+ +(8 rows) + +!ok + +# Correlated sub-query in the ON clause of a join after a comma. +# Same result on PostgreSQL 14. +with emp (ename, deptno, sal) + as (values ('SMITH', 20, 800), ('CLARK', 10, 2450), + ('ALLEN', 30, 1600), ('JONES', 20, 2975)), + dept (deptno) as (values (10), (20)) +select v.v, e.ename, d.deptno +from (values ('A'), ('M')) as v (v), + emp as e join dept as d + on d.deptno = e.deptno + and e.sal = (select max(sal) from emp as e2 where e2.deptno = d.deptno); ++---+-------+--------+ +| V | ENAME | DEPTNO | ++---+-------+--------+ +| A | CLARK | 10 | +| A | JONES | 20 | +| M | CLARK | 10 | +| M | JONES | 20 | ++---+-------+--------+ +(4 rows) + +!ok + # OR is a nestedLoop join select * from emp diff --git a/core/src/test/resources/sql/lateral.iq b/core/src/test/resources/sql/lateral.iq index f560c78d67..a7030af453 100644 --- a/core/src/test/resources/sql/lateral.iq +++ b/core/src/test/resources/sql/lateral.iq @@ -152,9 +152,9 @@ from dept, !ok # [CALCITE-6266] SqlValidatorException with LATERAL TABLE and JOIN -# The problem also occurred in ', LATERAL (SELECT ...) JOIN', because -# ',' should had lower precedence than 'JOIN', but should have had equal -# precedence. +# The problem also occurred in ', LATERAL (SELECT ...) JOIN'. 'JOIN' binds +# tighter than ',', so the LATERAL item is inside a join, and it must see +# the items before the ','. Same result on PostgreSQL 14. with dept (deptno, dname) as (values (10, 'ACCOUNTING'), (20,'RESEARCH')), emp (empno, deptno, ename) @@ -175,6 +175,76 @@ from dept, !ok +# LATERAL on the right of a join sees the item on the left of the join (v) +# and the item before the ',' (dept). Same result on PostgreSQL 14. +with dept (deptno, dname) + as (values (10, 'ACCOUNTING'), (20,'RESEARCH')), + emp (empno, deptno, ename) + as (values (7369, 20, 'SMITH'), (7782, 10, 'CLARK'), (7499, 30, 'ALLEN')) +select * +from dept, + (values 'A', 'M') as v (v) + cross join lateral (select * from emp + where emp.deptno = dept.deptno + and position(v.v in emp.ename) > 0) as emp; ++--------+------------+---+-------+---------+-------+ +| DEPTNO | DNAME | V | EMPNO | DEPTNO0 | ENAME | ++--------+------------+---+-------+---------+-------+ +| 10 | ACCOUNTING | A | 7782 | 10 | CLARK | +| 20 | RESEARCH | M | 7369 | 20 | SMITH | ++--------+------------+---+-------+---------+-------+ +(2 rows) + +!ok + +# As above, with LEFT JOIN; the rows that the join pads keep the value of +# "dept". Same result on PostgreSQL 14. +with dept (deptno, dname) + as (values (10, 'ACCOUNTING'), (20,'RESEARCH')), + emp (empno, deptno, ename) + as (values (7369, 20, 'SMITH'), (7782, 10, 'CLARK'), (7499, 30, 'ALLEN')) +select dept.deptno, emp.ename, v.v +from dept, + (values ('A'), ('M')) as v (v) + left join lateral (select * from emp + where emp.deptno = dept.deptno + and position(v.v in emp.ename) > 0) as emp on true; ++--------+-------+---+ +| DEPTNO | ENAME | V | ++--------+-------+---+ +| 10 | CLARK | A | +| 10 | | M | +| 20 | SMITH | M | +| 20 | | A | ++--------+-------+---+ +(4 rows) + +!ok + +# LATERAL sees two items before the ',' (v1 and dept) and the item on the +# left of the join (v2). Same result on PostgreSQL 14. +with dept (deptno, dname) + as (values (10, 'ACCOUNTING'), (20,'RESEARCH')), + emp (empno, deptno, ename) + as (values (7369, 20, 'SMITH'), (7782, 10, 'CLARK'), (7499, 30, 'ALLEN')) +select v1.v as v1, dept.deptno, emp.ename, v2.v as v2 +from (values ('A'), ('M')) as v1 (v), + dept, + (values ('A'), ('M')) as v2 (v) + cross join lateral (select * from emp + where emp.deptno = dept.deptno + and position(v2.v in emp.ename) > 0 + and v1.v = 'A') as emp; ++----+--------+-------+----+ +| V1 | DEPTNO | ENAME | V2 | ++----+--------+-------+----+ +| A | 10 | CLARK | A | +| A | 20 | SMITH | M | ++----+--------+-------+----+ +(2 rows) + +!ok + # UNNEST applied to VALUES with t (x, ys) as (values (1, array [2,3]), (4, array [5])) diff --git a/site/_docs/reference.md b/site/_docs/reference.md index 889645ae17..ed32963b2f 100644 --- a/site/_docs/reference.md +++ b/site/_docs/reference.md @@ -429,6 +429,12 @@ ## Grammar in those same conformance levels, any *column* in *insert* may be replaced by *columnDecl*, which has a similar effect to including it in an EXTEND clause. +In *tableExpression*, JOIN binds tighter than comma, as in the SQL standard: +"FROM a, b JOIN c ON c.x = b.x" is equivalent to +"FROM a, (b JOIN c ON c.x = b.x)", so the ON condition can reference +`b` and `c` but not `a`. A LATERAL item can reference the items to its left, +also those before a comma. + In *orderItem*, if *expression* is a positive integer *n*, it denotes the <em>n</em>th item in the SELECT clause. diff --git a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java index 952a2c97cf..88105a43cc 100644 --- a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java +++ b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java @@ -16,6 +16,7 @@ */ package org.apache.calcite.sql.parser; import org.apache.calcite.avatica.util.Quoting; +import org.apache.calcite.sql.JoinType; import org.apache.calcite.sql.SqlCall; import org.apache.calcite.sql.SqlDelete; import org.apache.calcite.sql.SqlDialect; @@ -3632,19 +3633,61 @@ void checkPeriodPredicate(Checker checker) { sql("select * from a as a2, b cross join c") .node( customMatches("custom", node -> { - // Parsed as left-deep: - // select * from (a as a2, b) cross join c - // (This is not valid SQL, but illustrates operator - // associativity.) + // JOIN binds tighter than comma: + // select * from a as a2, (b cross join c) assertThat(node, instanceOf(SqlSelect.class)); final SqlSelect select = (SqlSelect) node; assertThat(select.getFrom(), instanceOf(SqlJoin.class)); final SqlJoin from = requireNonNull((SqlJoin) select.getFrom()); - assertThat(from.getLeft(), instanceOf(SqlJoin.class)); - assertThat(from.getRight(), instanceOf(SqlIdentifier.class)); + assertThat(from.getJoinType(), is(JoinType.COMMA)); + assertThat(from.getLeft().getKind(), is(SqlKind.AS)); + assertThat(from.getRight(), instanceOf(SqlJoin.class)); })); } + private static String joinTree(SqlNode node) { + if (node instanceof SqlJoin) { + final SqlJoin join = (SqlJoin) node; + return join.getJoinType() + "(" + joinTree(join.getLeft()) + ", " + + joinTree(join.getRight()) + ")"; + } + return node.toString(); + } + + /** Returns a matcher for a query whose FROM clause has a given join tree; + * for example "COMMA(A, RIGHT(B, C))" for "FROM a, b RIGHT JOIN c". */ + private static Matcher<SqlNode> hasJoinTree(String expected) { + return customMatches(expected, node -> { + final SqlSelect select = (SqlSelect) node; + assertThat(joinTree(requireNonNull(select.getFrom())), is(expected)); + }); + } + + /** Test case for + * <a href="https://issues.apache.org/jira/browse/CALCITE-7911">[CALCITE-7911] + * FROM a, b JOIN c is parsed incorrectly</a>. */ + @Test void testJoinCommaPrecedence() { + // An explicit JOIN binds tighter than comma; joins and commas are each + // left-associative. + sql("select * from a, b right join c on b.x = c.x") + .node(hasJoinTree("COMMA(A, RIGHT(B, C))")); + sql("select * from a, b join c on true, d left join e on true") + .node(hasJoinTree("COMMA(COMMA(A, INNER(B, C)), LEFT(D, E))")); + sql("select * from a join b on true join c on true, d") + .node(hasJoinTree("COMMA(INNER(INNER(A, B), C), D)")); + // Printed SQL must parse to the same tree. + final String expected = "SELECT *\n" + + "FROM `A`,\n" + + "`B`\n" + + "RIGHT JOIN `C` ON (`B`.`X` = `C`.`X`)"; + sql("select * from a, (b right join c on b.x = c.x)") + .ok(expected) + .node(hasJoinTree("COMMA(A, RIGHT(B, C))")); + sql(expected) + .withConfig(c -> c.withQuoting(Quoting.BACK_TICK)) + .node(hasJoinTree("COMMA(A, RIGHT(B, C))")); + } + @Test void testInternalComma() { sql("select * from (a^,^ b) cross join c") .fails("(?s)Encountered \",\" at .*"); @@ -5071,19 +5114,19 @@ void checkPeriodPredicate(Checker checker) { + "match_condition orders.ts <= products.expiry\n" + "on orders.productid = products.productid"; final String expected0 = "SELECT *\n" - + "FROM (`ORDERS` " + + "FROM `ORDERS`\n" + "ASOF JOIN `PRODUCTS` " + "MATCH_CONDITION (`ORDERS`.`TS` <= `PRODUCTS`.`EXPIRY`) " - + "ON (`ORDERS`.`PRODUCTID` = `PRODUCTS`.`PRODUCTID`))"; + + "ON (`ORDERS`.`PRODUCTID` = `PRODUCTS`.`PRODUCTID`)"; sql(sql0).ok(expected0); final String sql1 = "select * from orders left asof join products\n" + "match_condition orders.ts <= products.expiry\n" + "on orders.productid = products.productid"; final String expected1 = "SELECT *\n" - + "FROM (`ORDERS` " + + "FROM `ORDERS`\n" + "LEFT ASOF JOIN `PRODUCTS` " + "MATCH_CONDITION (`ORDERS`.`TS` <= `PRODUCTS`.`EXPIRY`) " - + "ON (`ORDERS`.`PRODUCTID` = `PRODUCTS`.`PRODUCTID`))"; + + "ON (`ORDERS`.`PRODUCTID` = `PRODUCTS`.`PRODUCTID`)"; sql(sql1).ok(expected1); sql("select * from orders asof join products\n" @@ -6869,8 +6912,8 @@ private static Matcher<SqlNode> isCharLiteral(String s) { .ok("SELECT (ARRAY (SELECT `X`\n" + "FROM (VALUES (ROW(1))) AS `X`\n" + "ORDER BY `X`))"); - sql("SELECT array(SELECT x FROM (VALUES(1)) x^,^ SELECT x FROM (VALUES(1)) x)") - .fails("(?s)Encountered \", SELECT\" at.*"); + sql("SELECT array(SELECT x FROM (VALUES(1)) x, ^SELECT^ x FROM (VALUES(1)) x)") + .fails("(?s)Incorrect syntax near the keyword 'SELECT' at.*"); sql("SELECT array(1, ^SELECT^ x FROM (VALUES(1)) x)") .fails("(?s)Incorrect syntax near the keyword 'SELECT'.*"); } @@ -7009,8 +7052,8 @@ private static Matcher<SqlNode> isCharLiteral(String s) { sql("SELECT map(1, ^SELECT^ x FROM (VALUES(1)) x)") .fails("(?s)Incorrect syntax near the keyword 'SELECT'.*"); - sql("SELECT map(SELECT x FROM (VALUES(1)) x^,^ SELECT x FROM (VALUES(1)) x)") - .fails("(?s)Encountered \", SELECT\" at.*"); + sql("SELECT map(SELECT x FROM (VALUES(1)) x, ^SELECT^ x FROM (VALUES(1)) x)") + .fails("(?s)Incorrect syntax near the keyword 'SELECT' at.*"); } @Test void testVisitSqlInsertWithSqlShuttle() {
