Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -223,6 +224,69 @@ private static class AliasReplacementShuttle extends SqlShuttle {
}
}

/**
* Wraps a nested join Result into a subquery with a new alias.
* Required for dialects like ClickHouse that don't support nested JOIN syntax.
*
* @param input the Result from the nested join subtree
* @param outerAlias the alias to assign to the wrapped subquery
* @return a new Result with the wrapped SQL node
*/
protected Result wrapNestedJoin(Result input, RelNode inputRel, String outerAlias) {
// Get the original SQL node from the input Result
final SqlNode original = input.asSelect();
// Generate inner alias (different from outer)
String innerAlias = "t" + inputRel.getId();
// Wrap: original → (SELECT * FROM (original) AS newAlias)
SqlNode wrapped = wrapAsSelectStar(original, innerAlias);

Map<String, RelDataType> newAliases = new LinkedHashMap<>();

// Add the outer alias with the correct row type
newAliases.put(outerAlias, inputRel.getRowType());

return new Result(
wrapped,
input.clauses,
outerAlias,
null,
newAliases);
}

/**
* Wraps a subquery into a SELECT * FROM (subQuery) AS alias structure.
*
* @param subQuery the original SQL node to wrap
* @param alias the alias to assign to the wrapped subquery
* @return a SqlSelect node representing: SELECT * FROM (subQuery) AS alias
*/
protected SqlNode wrapAsSelectStar(SqlNode subQuery, String alias) {
final SqlParserPos pos = SqlParserPos.ZERO;

// Wrap subquery with alias: (subQuery) AS alias
SqlNode subQueryWithAlias =
SqlStdOperatorTable.AS.createCall(pos,
subQuery,
new SqlIdentifier(alias, pos));

// Build SELECT * FROM (subQuery) AS alias
return new SqlSelect(
pos,
null,
SqlNodeList.of(SqlIdentifier.star(pos)),
subQueryWithAlias,
null,
null,
null,
null,
null,
null,
null,
null,
null);
}


/** Visits a Join; called by {@link #dispatch} via reflection. */
public Result visit(Join e) {
switch (e.getJoinType()) {
Expand All @@ -233,7 +297,13 @@ 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.mustWrapNestedJoin(e)) {
String newAlias = "t" + e.getId(); // Calcite-style alias
rightResult = wrapNestedJoin(rightResult, e.getRight(), newAlias);
}

final Context leftContext = leftResult.qualifiedContext();
final Context rightContext = rightResult.qualifiedContext();
final SqlNode sqlCondition;
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/java/org/apache/calcite/sql/SqlDialect.java
Original file line number Diff line number Diff line change
Expand Up @@ -1825,4 +1825,7 @@ private ContextImpl(DatabaseProduct databaseProduct,
conformance, nullCollation, dataTypeSystem, jethroInfo);
}
}
public boolean mustWrapNestedJoin(RelNode relNode) {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@

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.Aggregate;
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;
Expand Down Expand Up @@ -404,4 +411,37 @@ private static void unparseFloor(SqlWriter writer, SqlCall call) {
call.operand(0).unparse(writer, 0, 0);
writer.endList(frame);
}

@Override public boolean mustWrapNestedJoin(RelNode rel) {
if (!(rel instanceof Join)) {
return false;
}
Join join = (Join) rel;

// ClickHouse primarily requires wrapping the right side of a JOIN
// when it contains a nested JOIN
return containsJoinRecursive(join.getRight());
}

/**
* Recursively checks if a RelNode contains a JOIN, looking through
* transparent single-input operators.
*/
private static boolean containsJoinRecursive(RelNode rel) {
if (rel instanceof Join || rel instanceof Correlate) {
return true;
}

// Look through transparent single-input operators
// These don't create subquery boundaries in the SQL
if (rel instanceof Project
|| rel instanceof Filter
|| rel instanceof Sort
|| rel instanceof Aggregate) { // Add Aggregate here
return containsJoinRecursive(rel.getInput(0));
}

return false;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.hasToString;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
Expand Down Expand Up @@ -11462,4 +11463,196 @@ 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</a>.
*
* <p>ClickHouse does not support nested JOIN syntax directly.
* When a JOIN's right side is another JOIN, it must be wrapped
* in a SELECT * FROM (...) subquery. */
@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 sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("LEFT JOIN (SELECT *"));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think use following is fine. Just using a expected string.

    sql(query)
        .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
        .withClickHouse()
        .ok(expected);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I agree that using ok(expected) is generally preferred
and is what we should use whenever possible.

In this case, however, the generated SQL contains auto-generated subquery aliases
(e.g. t220414 / t220415) coming from Calcite’s alias generation, and these IDs are
not stable across different runs (for example, gradlew test vs IDE runs).
This makes a strict ok(expected) assertion flaky.

To avoid introducing non-deterministic tests, this test verifies the structural
properties of the generated SQL instead (i.e. whether the required wrapper
SELECT * FROM (...) is present or absent), rather than asserting exact alias
names.

I’ve added a comment in the test to document this reasoning. If alias generation
becomes stable in the future, we can switch this back to ok(expected).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that if a fixed SQL statement is processed in a specific way, the alias should be stable. Can you explain why it might be unstable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this issue remains to be discussed and has not been resolved.

assertThat(sql, containsString("FROM (SELECT `DEPT`.`DEPTNO`, `DEPT`.`DNAME`, `DEPT0`.`LOC`"));
assertThat(sql, containsString("INNER JOIN `SCOTT`.`DEPT` AS `DEPT0`"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

/** Test that simple JOINs without nesting are not wrapped. */
@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";
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 INNER JOIN with nested right side. */
@Test void testClickHouseNestedInnerJoin() {
final String query = "SELECT e.empno, j.dname\n"
+ "FROM emp e\n"
+ "INNER 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";

final String sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("INNER JOIN (SELECT *"));
assertThat(sql, containsString("FROM (SELECT `DEPT`.`DEPTNO`, `DEPT`.`DNAME`"));
assertThat(sql, containsString("INNER JOIN `SCOTT`.`DEPT` AS `DEPT0`"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

/** Test three-way JOIN where optimization may create nested structure. */
@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";
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);
}

/** Test nested JOIN with WHERE clause in subquery. */
@Test void testClickHouseNestedJoinWithWhere() {
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"
+ " WHERE d1.deptno < 30\n"
+ ") AS j ON e.deptno = j.deptno";

final String sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("LEFT JOIN (SELECT *"));
assertThat(sql, containsString("FROM (SELECT `DEPT`.`DEPTNO`, `DEPT`.`DNAME`"));
assertThat(sql, containsString("INNER JOIN `SCOTT`.`DEPT` AS `DEPT0`"));
assertThat(sql, containsString("WHERE"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

/** Test nested JOIN with aggregation. */
@Test void testClickHouseNestedJoinWithAggregation() {
final String query = "SELECT e.empno, j.cnt\n"
+ "FROM emp e\n"
+ "LEFT JOIN (\n"
+ " SELECT d1.deptno, COUNT(*) as cnt\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 sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("LEFT JOIN (SELECT *"));
assertThat(sql, containsString("COUNT(*) AS `CNT`"));
assertThat(sql, containsString("INNER JOIN `SCOTT`.`DEPT` AS `DEPT0`"));
assertThat(sql, containsString("GROUP BY"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

/** Test nested JOIN with multiple conditions. */
@Test void testClickHouseNestedJoinMultipleConditions() {
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 "
+ "AND d1.dname = d2.dname\n"
+ ") AS j ON e.deptno = j.deptno";

final String sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("LEFT JOIN (SELECT *"));
assertThat(sql, containsString("FROM (SELECT `DEPT`.`DEPTNO`, `DEPT`.`DNAME`"));
assertThat(sql, containsString("AND `DEPT`.`DNAME` = `DEPT0`.`DNAME`"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

/** Test that MySQL dialect does not wrap nested JOINs. */
@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 should NOT add extra wrapping for nested JOINs
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);
}

/** Test RIGHT JOIN with nested structure. */
@Test void testClickHouseNestedRightJoin() {
final String query = "SELECT e.empno, j.dname\n"
+ "FROM emp e\n"
+ "RIGHT 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";

final String sql = sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
.withClickHouse()
.exec();

assertThat(sql, containsString("RIGHT JOIN (SELECT *"));
assertThat(sql, containsString("FROM (SELECT `DEPT`.`DEPTNO`"));
assertThat(sql, containsString("INNER JOIN `SCOTT`.`DEPT` AS `DEPT0`"));
assertTrue(sql.matches("(?s).*AS `t\\d+`\\) AS `t\\d+`.*"));
}

}
Loading