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

mbudiu 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 7c9a58c3a7 [CALCITE-7279] ClickHouse dialect should wrap nested JOINs 
with explicit column aliases
7c9a58c3a7 is described below

commit 7c9a58c3a71e3c44814540fafcc1f4fc60833b8d
Author: krooswu <[email protected]>
AuthorDate: Wed Dec 17 22:55:10 2025 +0800

    [CALCITE-7279] ClickHouse dialect should wrap nested JOINs with explicit 
column aliases
---
 .../calcite/rel/rel2sql/RelToSqlConverter.java     |  88 +++++++++++++++-
 .../java/org/apache/calcite/sql/SqlDialect.java    |  18 ++++
 .../calcite/sql/dialect/ClickHouseSqlDialect.java  |  53 ++++++++++
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java | 111 +++++++++++++++++++++
 4 files changed, 269 insertions(+), 1 deletion(-)

diff --git 
a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java 
b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
index 47834ea9d0..592df51a0b 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
@@ -223,6 +223,74 @@ private static class AliasReplacementShuttle extends 
SqlShuttle {
     }
   }
 
+  /**
+   * Wraps a nested join into a subquery with explicit column aliases.
+   *
+   * <p>This is specifically required for dialects like ClickHouse where the
+   * identifier resolver cannot resolve columns with internal table qualifiers
+   * (e.g., 'd1.loc') when they are wrapped in a subquery alias. By forcing
+   * an explicit 'AS' projection for every column, we ensure the identifiers
+   * are flattened and visible to the outer query block.
+   *
+   * @param input     The Result of the nested join branch
+   * @param inputRel  The RelNode representing the join branch to extract row 
types
+   * @param outerAlias The alias to be assigned to the wrapped subquery
+   * @return A new Result containing the wrapped SQL with explicit aliases
+   */
+  protected Result wrapNestedJoin(Result input, RelNode inputRel, String 
outerAlias) {
+    final SqlParserPos pos = SqlParserPos.ZERO;
+
+    // Obtain a SqlSelect representation of the input.
+    // We manually rewrite the SelectList to enforce explicit column aliases,
+    // preventing ClickHouse scoping issues while avoiding redundant sub-query 
nesting.
+    final SqlSelect innerSelect = input.asSelect();
+    final SqlNodeList originalSelectList = innerSelect.getSelectList();
+    final List<String> fieldNames = inputRel.getRowType().getFieldNames();
+
+    final List<SqlNode> newSelectList = new ArrayList<>();
+
+    // Iterate through the fields to build explicit projections.
+    // Example: transforms 'd1.deptno' into 'd1.deptno AS deptno'.
+    for (int i = 0; i < fieldNames.size(); i++) {
+      SqlNode expr = originalSelectList.get(i);
+      String targetName = fieldNames.get(i);
+
+      // If the expression is already aliased, strip the AS to get the raw 
expression.
+      if (expr.getKind() == SqlKind.AS) {
+        expr = ((SqlCall) expr).operand(0);
+      }
+
+      // Force an explicit alias to mask internal table qualifiers.
+      // This ensures the outer JOIN can resolve the column name
+      // without being confused by nested scope identifiers.
+      newSelectList.add(
+          SqlStdOperatorTable.AS.createCall(
+              pos,
+              expr,
+              new SqlIdentifier(targetName, pos)));
+    }
+
+    // Update the select list of the inner query with flattened aliases.
+    innerSelect.setSelectList(new SqlNodeList(newSelectList, pos));
+
+    // Wrap the modified Select node with the outer alias (e.g., AS j).
+    SqlNode wrappedNode =
+        SqlStdOperatorTable.AS.createCall(pos,
+        innerSelect,
+        new SqlIdentifier(outerAlias, pos));
+
+    // Return a new Result with empty clause lists. This "finalizes" the
+    // current sub-query and ensures the Implementor won't add
+    // redundant SELECT wrappers in subsequent steps.
+    return new Result(
+        wrappedNode,
+        Collections.emptyList(),
+        outerAlias,
+        inputRel.getRowType(),
+        ImmutableMap.of(outerAlias, inputRel.getRowType()));
+  }
+
+
   /** Visits a Join; called by {@link #dispatch} via reflection. */
   public Result visit(Join e) {
     switch (e.getJoinType()) {
@@ -233,7 +301,25 @@ public Result visit(Join e) {
       break;
     }
     final Result leftResult = visitInput(e, 0).resetAlias();
-    final Result rightResult = visitInput(e, 1).resetAlias();
+    Result rightResult = visitInput(e, 1).resetAlias();
+
+    if (dialect.shouldWrapNestedJoin(e)) {
+
+      Set<String> usedNames = new HashSet<>(e.getRowType().getFieldNames());
+      // Add both left and right aliases
+      if (leftResult.neededAlias != null) {
+        usedNames.add(leftResult.neededAlias);
+      }
+      if (rightResult.neededAlias != null) {
+        usedNames.add(rightResult.neededAlias);
+      }
+
+      String safeAlias =
+          SqlValidatorUtil.uniquify("t", usedNames, 
SqlValidatorUtil.EXPR_SUGGESTER);
+
+      rightResult = wrapNestedJoin(rightResult, e.getRight(), safeAlias);
+    }
+
     final Context leftContext = leftResult.qualifiedContext();
     final Context rightContext = rightResult.qualifiedContext();
     parseCorrelTable(e, leftContext.implementor().joinContext(leftContext, 
rightContext));
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
index a148e6ee6c..869976f0d4 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
@@ -1825,4 +1825,22 @@ private ContextImpl(DatabaseProduct databaseProduct,
           conformance, nullCollation, dataTypeSystem, jethroInfo);
     }
   }
+
+  /**
+   * Returns whether this dialect requires wrapping a nested JOIN in a 
subquery with <b>mandatory
+   * aliases</b> for both the table and its projected columns.
+   *
+   * <p>Example for ClickHouse (returns true):
+   * <pre>{@code
+   * // Before: ... LEFT JOIN (dept d1 INNER JOIN dept d2 ON ...) AS j ON ...
+   * // After:  ... LEFT JOIN (
+   * //           SELECT d1.deptno AS deptno, d2.loc AS loc ...
+   * //           FROM dept d1 INNER JOIN dept d2 ON ...
+   * //         ) AS t0 ON ...
+   * }</pre>
+   */
+  public boolean shouldWrapNestedJoin(RelNode relNode) {
+    return false;
+  }
+
 }
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
index f33e49bb51..cd9e736f24 100644
--- 
a/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
+++ 
b/core/src/main/java/org/apache/calcite/sql/dialect/ClickHouseSqlDialect.java
@@ -18,6 +18,12 @@
 
 import org.apache.calcite.avatica.util.TimeUnitRange;
 import org.apache.calcite.config.NullCollation;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.core.Correlate;
+import org.apache.calcite.rel.core.Filter;
+import org.apache.calcite.rel.core.Join;
+import org.apache.calcite.rel.core.Project;
+import org.apache.calcite.rel.core.Sort;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeSystem;
 import org.apache.calcite.rel.type.RelDataTypeSystemImpl;
@@ -400,4 +406,51 @@ private static void unparseFloor(SqlWriter writer, SqlCall 
call) {
     call.operand(0).unparse(writer, 0, 0);
     writer.endList(frame);
   }
+
+  @Override public boolean shouldWrapNestedJoin(RelNode rel) {
+    if (!(rel instanceof Join)) {
+      return false;
+    }
+    Join join = (Join) rel;
+
+    // ClickHouse requires wrapping the right-side input if it's a JOIN
+    // to ensure that internal table qualifiers are flattened into explicit 
aliases.
+    // This solves the Code 47 UNKNOWN_IDENTIFIER error.
+    RelNode right = join.getRight();
+
+    // If the right side is a Join or a Project containing a Join, it needs 
aliasing protection
+    return right instanceof Join || containsJoinRecursive(right);
+  }
+
+  /**
+   * Checks whether the given RelNode contains a JOIN that is directly exposed
+   * to the outer scope, which could lead to "Unknown Identifier" errors in 
ClickHouse.
+   *
+   * <p>ClickHouse (v25.x+) has strict scoping rules: when a JOIN appears on 
the
+   * right side of another JOIN, internal table qualifiers (e.g., 'd2.loc') are
+   * stripped and become invisible to the outer query unless they are 
explicitly
+   * aliased within a subquery.
+   *
+   * <p>We only check for JOINs wrapped by transparent single-input operators
+   * (Project, Filter, Sort) because these operators are typically collapsed
+   * into the same SELECT block, exposing the problematic JOIN structure to
+   * the outer boundary.
+   */
+  private static boolean containsJoinRecursive(RelNode rel) {
+    if (rel instanceof Join || rel instanceof Correlate) {
+      return true;
+    }
+
+    // Look through transparent single-input operators.
+    // We exclude Aggregate here because it naturally triggers a subquery
+    // boundary in RelToSqlConverter, which already provides the necessary 
isolation.
+    if (rel instanceof Project
+        || rel instanceof Filter
+        || rel instanceof Sort) {
+      return rel.getInputs().size() == 1 && 
containsJoinRecursive(rel.getInput(0));
+    }
+
+    return false;
+  }
+
 }
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 aba4ee7eaa..0c8e009c6e 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
@@ -11597,4 +11597,115 @@ public Sql schema(CalciteAssert.SchemaSpec 
schemaSpec) {
           relFn, transforms);
     }
   }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7279";>[CALCITE-7279]
+   * ClickHouse dialect should wrap nested JOINs with explicit aliasing</a>. */
+  @Test void testClickHouseNestedJoin() {
+    final String query = "SELECT e.empno, j.dname, j.loc\n"
+        + "FROM emp e\n"
+        + "LEFT JOIN (\n"
+        + "  SELECT d1.deptno, d1.dname, d2.loc\n"
+        + "  FROM dept d1\n"
+        + "  INNER JOIN dept d2 ON d1.deptno = d2.deptno\n"
+        + ") AS j ON e.deptno = j.deptno";
+
+    final String expected = "SELECT `EMP`.`EMPNO`, `t0`.`DNAME`, `t0`.`LOC`\n"
+        + "FROM `SCOTT`.`EMP`\n"
+        + "LEFT JOIN (SELECT `DEPT`.`DEPTNO` AS `DEPTNO`, `DEPT`.`DNAME` AS 
`DNAME`,"
+        + " `DEPT0`.`LOC` AS `LOC`\n"
+        + "FROM `SCOTT`.`DEPT`\n"
+        + "INNER JOIN `SCOTT`.`DEPT` AS `DEPT0` ON `DEPT`.`DEPTNO` = 
`DEPT0`.`DEPTNO`) "
+        + "AS `t0` ON `EMP`.`DEPTNO` = `t0`.`DEPTNO`";
+
+    sql(query)
+        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+        .withClickHouse()
+        .ok(expected);
+  }
+
+  /** Test that simple JOINs without nesting are not wrapped unnecessarily. */
+  @Test void testClickHouseSimpleJoinNotWrapped() {
+    final String query = "SELECT e.empno, d.dname\n"
+        + "FROM emp e\n"
+        + "LEFT JOIN dept d ON e.deptno = d.deptno";
+
+    // Simple joins should remain flat as standard SQL
+    final String expected = "SELECT `EMP`.`EMPNO`, `DEPT`.`DNAME`\n"
+        + "FROM `SCOTT`.`EMP`\n"
+        + "LEFT JOIN `SCOTT`.`DEPT` ON `EMP`.`DEPTNO` = `DEPT`.`DEPTNO`";
+
+    sql(query)
+        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+        .withClickHouse()
+        .ok(expected);
+  }
+
+  /** Test nested JOIN with aggregation to ensure expressions like COUNT(*) 
are aliased. */
+  @Test void testClickHouseNestedJoinWithAggregation() {
+    final String query = "SELECT e.empno, j.\"EXPR$1\"\n"
+        + "FROM emp e\n"
+        + "LEFT JOIN (\n"
+        + "  SELECT d1.deptno, COUNT(*)\n"
+        + "  FROM dept d1\n"
+        + "  INNER JOIN dept d2 ON d1.deptno = d2.deptno\n"
+        + "  GROUP BY d1.deptno\n"
+        + ") AS j ON e.deptno = j.deptno";
+
+    final String expected = "SELECT `EMP`.`EMPNO`, `t0`.`EXPR$1`\n"
+        + "FROM `SCOTT`.`EMP`\n"
+        + "LEFT JOIN (SELECT `DEPT`.`DEPTNO`, COUNT(*) AS `EXPR$1`\n"
+        + "FROM `SCOTT`.`DEPT`\n"
+        + "INNER JOIN `SCOTT`.`DEPT` AS `DEPT0` ON `DEPT`.`DEPTNO` = 
`DEPT0`.`DEPTNO`\n"
+        + "GROUP BY `DEPT`.`DEPTNO`) AS `t0` ON `EMP`.`DEPTNO` = 
`t0`.`DEPTNO`";
+
+    sql(query)
+        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+        .withClickHouse()
+        .ok(expected);
+  }
+
+  /** Test three-way JOIN to ensure the converter handles linear joins 
normally. */
+  @Test void testClickHouseThreeWayJoin() {
+    final String query = "SELECT e.empno, d1.dname, d2.loc\n"
+        + "FROM emp e\n"
+        + "INNER JOIN dept d1 ON e.deptno = d1.deptno\n"
+        + "INNER JOIN dept d2 ON d1.deptno = d2.deptno";
+
+    // Standard multi-way joins shouldn't be forced into subqueries unless 
nested on the right
+    final String expected = "SELECT `EMP`.`EMPNO`, `DEPT`.`DNAME`, 
`DEPT0`.`LOC`\n"
+        + "FROM `SCOTT`.`EMP`\n"
+        + "INNER JOIN `SCOTT`.`DEPT` ON `EMP`.`DEPTNO` = `DEPT`.`DEPTNO`\n"
+        + "INNER JOIN `SCOTT`.`DEPT` AS `DEPT0` ON `DEPT`.`DEPTNO` = 
`DEPT0`.`DEPTNO`";
+
+    sql(query)
+        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+        .withClickHouse()
+        .ok(expected);
+  }
+
+  /** Regression test: Ensure MySQL dialect remains unaffected by 
ClickHouse-specific fix. */
+  @Test void testMysqlNestedJoinNotWrapped() {
+    final String query = "SELECT e.empno, j.dname\n"
+        + "FROM emp e\n"
+        + "LEFT JOIN (\n"
+        + "  SELECT d1.deptno, d1.dname\n"
+        + "  FROM dept d1\n"
+        + "  INNER JOIN dept d2 ON d1.deptno = d2.deptno\n"
+        + ") AS j ON e.deptno = j.deptno";
+
+    // MySQL supports nested join syntax; no additional wrapping select should 
be added by our fix.
+    final String expected = "SELECT `EMP`.`EMPNO`, `t`.`DNAME`\n"
+        + "FROM `SCOTT`.`EMP`\n"
+        + "LEFT JOIN (SELECT `DEPT`.`DEPTNO`, `DEPT`.`DNAME`\n"
+        + "FROM `SCOTT`.`DEPT`\n"
+        + "INNER JOIN `SCOTT`.`DEPT` AS `DEPT0` ON `DEPT`.`DEPTNO` = 
`DEPT0`.`DEPTNO`) AS `t` "
+        + "ON `EMP`.`DEPTNO` = `t`.`DEPTNO`";
+
+    sql(query)
+        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+        .withMysql()
+        .ok(expected);
+  }
+
 }

Reply via email to