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 607126846b [CALCITE-7343] RelToSqlConverter generate wrong sql when
scalar correlated sub-query in Project
607126846b is described below
commit 607126846b12348beae54bbcbb7d1f8ea4a44a0d
Author: krooswu <[email protected]>
AuthorDate: Wed Feb 25 23:49:08 2026 +0800
[CALCITE-7343] RelToSqlConverter generate wrong sql when scalar correlated
sub-query in Project
---
.../calcite/rel/rel2sql/RelToSqlConverter.java | 74 ++++++++++++++++++-
.../apache/calcite/rel/rel2sql/SqlImplementor.java | 61 +++++++++++++---
.../calcite/rel/rel2sql/RelToSqlConverterTest.java | 84 ++++++++++++++++++++--
3 files changed, 203 insertions(+), 16 deletions(-)
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 592df51a0b..0404fa7bc5 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
@@ -604,14 +604,82 @@ private static boolean selectListRequired(Context
context) {
return false;
}
- /** Visits a Project; called by {@link #dispatch} via reflection. */
+ /**
+ * Extracts the table name from a SqlNode if it represents a simple table
reference.
+ * Returns null for complex nodes like subqueries or joins.
+ *
+ * <p>Examples:
+ * <ul>
+ * <li>"product" → "product"</li>
+ * <li>"foodmart.product" → "product"</li>
+ * <li>"SCOTT"."EMP" → "EMP"</li>
+ * <li>Subquery/Join/Other → null</li>
+ * </ul>
+ *
+ * @param node The SQL node to examine
+ * @return The table name if it's a simple identifier, null otherwise
+ */
+ private @Nullable String unqualifiedName(SqlNode node) {
+ if (node instanceof SqlIdentifier) {
+ SqlIdentifier id = (SqlIdentifier) node;
+ // Return the last component (table name)
+ return id.names.get(id.names.size() - 1);
+ }
+
+ // All other cases: return null
+ return null;
+ }
+ /**
+ * Visits a {@link Project} and converts it to a {@link SqlSelect}.
+ *
+ * <p>If the project defines correlation variables (e.g., via {@code $cor0}),
+ * this method ensures that the input relation is assigned a stable alias
+ * (either the natural table name or a synthetic alias like 't').
+ * This enables nested correlated subqueries to correctly qualify their
+ * column references back to this project's scope.
+ *
+ * <p>For simple table scans, it avoids forcing an explicit 'AS' clause
+ * to maintain compatibility with DML statements (like UPDATE/DELETE)
+ * in certain SQL dialects.
+ */
public Result visit(Project e) {
// If the input is a Sort, wrap SELECT is not required.
final Result x;
+ final Set<CorrelationId> definedHere = e.getVariablesSet();
+ boolean pushed = !definedHere.isEmpty();
+
+ // Visit input node
+ Result inputResult;
if (e.getInput() instanceof Sort) {
- x = visitInput(e, 0);
+ inputResult = visitInput(e, 0);
+ } else {
+ inputResult = visitInput(e, 0, Clause.SELECT);
+ }
+
+ // If this Project defines correlations, fill in alias and force explicit
generation
+ // Resolve the correlation alias using a three-level priority:
+ // 1. Use the existing alias from inputResult if it's already defined.
+ // 2. If not, extract the natural table name to maintain DML compatibility.
+ // 3. Fallback to 't' for anonymous relations (e.g., Joins or Sub-queries).
+ //
+ // 't' is safe and standard in Calcite's SqlImplementor because:
+ // - Anonymous relations in the FROM clause require an alias in most
dialects.
+ // - SQL name shadowing rules ensure that nested sub-queries correctly bind
+ // to the nearest qualifying 't' in their scope.
+ if (pushed) {
+ String alias = inputResult.neededAlias;
+ if (alias != null) {
+ x = inputResult.resetAliasForCorrelation(alias,
e.getInput().getRowType());
+ } else {
+ alias = unqualifiedName(inputResult.node);
+ if (alias == null) {
+ alias = "t";
+ }
+ x = inputResult.resetAliasForCorrelation
+ (alias, e.getInput().getRowType());
+ }
} else {
- x = visitInput(e, 0, Clause.SELECT);
+ x = inputResult;
}
parseCorrelTable(e, x);
final Builder builder = x.builder(e);
diff --git
a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
index 6d60d52aa4..dec24ca1f2 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/SqlImplementor.java
@@ -1910,16 +1910,24 @@ public class Result {
private final @Nullable RelNode expectedRel;
private final boolean needNew;
+ /**
+ * Whether to force explicit alias generation in FROM clause.
+ * Set to true when this Result is used in a correlation context
+ * where the table alias must be explicit even if the dialect
+ * normally supports implicit aliases.
+ */
+ private final boolean forceExplicitAlias;
+
public Result(SqlNode node, Collection<Clause> clauses, @Nullable String
neededAlias,
@Nullable RelDataType neededType, Map<String, RelDataType> aliases) {
this(node, clauses, neededAlias, neededType, aliases, false, false,
- ImmutableSet.of(), null);
+ ImmutableSet.of(), null, false);
}
private Result(SqlNode node, Collection<Clause> clauses, @Nullable String
neededAlias,
@Nullable RelDataType neededType, Map<String, RelDataType> aliases,
boolean anon,
boolean ignoreClauses, Set<Clause> expectedClauses,
- @Nullable RelNode expectedRel) {
+ @Nullable RelNode expectedRel, boolean forceExplicitAlias) {
this.node = node;
this.neededAlias = neededAlias;
this.neededType = neededType;
@@ -1929,6 +1937,7 @@ private Result(SqlNode node, Collection<Clause> clauses,
@Nullable String needed
this.ignoreClauses = ignoreClauses;
this.expectedClauses = ImmutableSet.copyOf(expectedClauses);
this.expectedRel = expectedRel;
+ this.forceExplicitAlias = forceExplicitAlias;
final Set<Clause> clauses2 =
ignoreClauses ? ImmutableSet.of() : expectedClauses;
this.needNew = expectedRel != null
@@ -2241,9 +2250,23 @@ public SqlSelect subSelect() {
* INTERSECT, EXCEPT) remain as is. */
public SqlSelect asSelect() {
if (node instanceof SqlSelect) {
- return (SqlSelect) node;
+ SqlSelect select = (SqlSelect) node;
+ // Check if we need to add explicit alias to FROM clause
+ if (forceExplicitAlias && neededAlias != null) {
+ SqlNode from = select.getFrom();
+
+ // Only add alias if FROM doesn't already have one
+ if (from != null && from.getKind() != SqlKind.AS) {
+ SqlNode newFrom =
+ SqlStdOperatorTable.AS.createCall(POS, from,
+ new SqlIdentifier(neededAlias, POS));
+ select.setFrom(newFrom);
+ }
+ }
+ return select;
}
- if (!dialect.hasImplicitTableAlias() || hasConflictTableAlias(node)) {
+ // For non-SELECT nodes, wrap in SELECT *
+ if (forceExplicitAlias || !dialect.hasImplicitTableAlias() ||
hasConflictTableAlias(node)) {
return wrapSelect(asFrom());
}
return wrapSelect(node);
@@ -2369,7 +2392,7 @@ public Result resetAlias() {
} else {
return new Result(node, clauses, neededAlias, neededType,
ImmutableMap.of(neededAlias, castNonNull(neededType)), anon,
ignoreClauses,
- expectedClauses, expectedRel);
+ expectedClauses, expectedRel, false);
}
}
@@ -2382,14 +2405,36 @@ public Result resetAlias() {
public Result resetAlias(String alias, RelDataType type) {
return new Result(node, clauses, alias, neededType,
ImmutableMap.of(alias, type), anon, ignoreClauses,
- expectedClauses, expectedRel);
+ expectedClauses, expectedRel, false);
+ }
+
+ /**
+ * Sets the alias and forces explicit alias generation in FROM clause.
+ * Used when correlation requires an explicit table alias.
+ *
+ * @param alias New alias to use
+ * @param type Type of the node associated with the alias
+ * @return New Result with forced explicit alias
+ */
+ public Result resetAliasForCorrelation(String alias, RelDataType type) {
+ return new Result(
+ node,
+ clauses,
+ alias,
+ neededType,
+ ImmutableMap.of(alias, type),
+ anon,
+ ignoreClauses,
+ expectedClauses,
+ expectedRel,
+ true); // Force explicit alias
}
/** Returns a copy of this Result, overriding the value of {@code anon}. */
Result withAnon(boolean anon) {
return anon == this.anon ? this
: new Result(node, clauses, neededAlias, neededType, aliases, anon,
- ignoreClauses, expectedClauses, expectedRel);
+ ignoreClauses, expectedClauses, expectedRel, false);
}
/** Returns a copy of this Result, overriding the value of
@@ -2401,7 +2446,7 @@ Result withExpectedClauses(boolean ignoreClauses,
&& expectedRel == this.expectedRel
? this
: new Result(node, clauses, neededAlias, neededType, aliases, anon,
- ignoreClauses, ImmutableSet.copyOf(expectedClauses),
expectedRel);
+ ignoreClauses, ImmutableSet.copyOf(expectedClauses),
expectedRel, 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 a98f7fd1f8..4250181e66 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
@@ -11107,7 +11107,7 @@ private void checkLiteral2(String expression, String
expected) {
+ "FROM \"scott\".\"EMP\"\n"
+ "GROUP BY \"DEPTNO\"\n"
+ "HAVING \"DEPTNO\" = \"DEPT\".\"DEPTNO\") AS \"$f2\"\n"
- + "FROM \"scott\".\"DEPT\"";
+ + "FROM \"scott\".\"DEPT\" AS \"DEPT\"";
relFn(relFn).ok(expected);
}
@@ -11256,7 +11256,7 @@ private void checkLiteral2(String expression, String
expected) {
final String expected = "SELECT (SELECT COUNT(*)\n"
+ "FROM \"foodmart\".\"employee\"\n"
+ "WHERE \"product\".\"product_id\" >= 2), 3\n"
- + "FROM \"foodmart\".\"product\"";
+ + "FROM \"foodmart\".\"product\" AS \"product\"";
sql(sql).ok(expected);
}
@@ -11651,9 +11651,10 @@ public Sql schema(CalciteAssert.SchemaSpec schemaSpec)
{
}
}
- /** Test case for
+ /** Test cases for
* <a
href="https://issues.apache.org/jira/browse/CALCITE-7279">[CALCITE-7279]
- * ClickHouse dialect should wrap nested JOINs with explicit aliasing</a>. */
+ * 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"
@@ -11757,8 +11758,81 @@ public Sql schema(CalciteAssert.SchemaSpec schemaSpec)
{
sql(query)
.schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
- .withMysql()
+ .withMysql();
+ }
+
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7343">[CALCITE-7343]
+ RelToSqlConverter generate wrong sql when scalar correlated sub-query in
Project </a>. */
+ @Test void testProjectScalarSubquery() {
+ final String sql = "SELECT \"EMPNO\",\n"
+ + " (SELECT COUNT(*) AS \"c\" FROM \"EMP\" WHERE \"MGR\" <
\"m\".\"MGR\") AS \"$f1\"\n"
+ + "FROM \"EMP\" AS \"m\"\n"
+ + "WHERE \"SAL\" > 10";
+
+ final String expected = "SELECT \"EMPNO\", "
+ + "(SELECT COUNT(*) AS \"c\"\n"
+ + "FROM \"SCOTT\".\"EMP\"\n"
+ + "WHERE \"MGR\" < \"t\".\"MGR\") AS \"$f1\"\n"
+ + "FROM \"SCOTT\".\"EMP\" AS \"t\"\n"
+ + "WHERE CAST(\"SAL\" AS DECIMAL(12, 2)) > 10.00";
+
+ sql(sql)
+ .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+ .withCalcite().ok(expected);
+ }
+ @Test void testProjectDeeplyNestedScalarSubquery() {
+ final String sql = "SELECT \"EMPNO\",\n"
+ + " (SELECT MAX((SELECT COUNT(*) FROM \"DEPT\" "
+ + "WHERE \"DEPTNO\" = \"m\".\"DEPTNO\"))\n"
+ + " FROM \"DEPT\" WHERE \"LOC\" = 'NEW YORK') AS \"$f1\"\n"
+ + "FROM \"EMP\" AS \"m\"";
+
+ final String expected = "SELECT \"EMPNO\", "
+ + "(SELECT MAX((SELECT COUNT(*)\n"
+ + "FROM \"SCOTT\".\"DEPT\"\n"
+ + "WHERE \"DEPTNO\" = \"EMP\".\"DEPTNO\"))\n"
+ + "FROM \"SCOTT\".\"DEPT\"\n"
+ + "WHERE \"LOC\" = 'NEW YORK') AS \"$f1\"\n"
+ + "FROM \"SCOTT\".\"EMP\" AS \"EMP\"";
+
+ sql(sql)
+ .schema(CalciteAssert.SchemaSpec.JDBC_SCOTT)
+ .withCalcite()
.ok(expected);
}
+ @Test void testMultiLayerProjectCorrelation() {
+ final String sql = "SELECT \"EMPNO\" + 1, \n"
+ + " (SELECT \"DNAME\" FROM \"DEPT\" WHERE \"DEPTNO\" =
\"sub\".\"DEPTNO\")\n"
+ + "FROM (SELECT * FROM \"EMP\") AS \"sub\"";
+
+ final String expected = "SELECT \"EMPNO\" + 1, "
+ + "(SELECT \"DNAME\"\nFROM \"SCOTT\".\"DEPT\"\n"
+ + "WHERE \"DEPTNO\" = \"t\".\"DEPTNO\")\n"
+ + "FROM \"SCOTT\".\"EMP\" AS \"t\"";
+
+ sql(sql).schema(CalciteAssert.SchemaSpec.JDBC_SCOTT).ok(expected);
+ }
+
+ @Test void testMultiLevelCrossReference() {
+ final String sql = "SELECT \"e\".\"ENAME\",\n"
+ + " (SELECT COUNT(*)\n"
+ + " FROM (SELECT \"d\".\"DEPTNO\", \"d\".\"DNAME\" FROM \"DEPT\"
\"d\" "
+ + " JOIN \"BONUS\" \"b\" ON \"d\".\"DEPTNO\" =
\"e\".\"DEPTNO\") AS \"mid\"\n"
+ + " WHERE \"mid\".\"DNAME\" = (SELECT \"DNAME\" FROM \"DEPT\" "
+ + " WHERE \"DEPTNO\" = \"e\".\"DEPTNO\" "
+ + " AND \"LOC\" = \"mid\".\"DNAME\"))\n"
+ + "FROM \"EMP\" AS \"e\"";
+ final String expected = "SELECT \"ENAME\", (SELECT COUNT(*)\n"
+ + "FROM (SELECT \"DEPT\".\"DEPTNO\", \"DEPT\".\"DNAME\"\n"
+ + "FROM \"SCOTT\".\"DEPT\"\n"
+ + "INNER JOIN \"SCOTT\".\"BONUS\" ON \"DEPT\".\"DEPTNO\" =
\"EMP\".\"DEPTNO\") AS \"t\"\n"
+ + "WHERE \"DNAME\" = (SELECT \"DNAME\"\n"
+ + "FROM \"SCOTT\".\"DEPT\"\n"
+ + "WHERE \"DEPTNO\" = \"EMP\".\"DEPTNO\" AND \"LOC\" =
\"t\".\"DNAME\"))\n"
+ + "FROM \"SCOTT\".\"EMP\" AS \"EMP\"";
+
+ sql(sql).schema(CalciteAssert.SchemaSpec.JDBC_SCOTT).ok(expected);
+ }
}