This is an automated email from the ASF dual-hosted git repository.
mihaibudiu 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 e7ec483951 [CALCITE-7602] ROW(*) loses column names
e7ec483951 is described below
commit e7ec483951f25838f5f561e919791714ce2c7ccb
Author: Mihai Budiu <[email protected]>
AuthorDate: Mon Jun 15 15:05:40 2026 -0700
[CALCITE-7602] ROW(*) loses column names
Signed-off-by: Mihai Budiu <[email protected]>
---
.../org/apache/calcite/sql/fun/SqlRowOperator.java | 8 +-
.../calcite/sql/validate/SqlValidatorImpl.java | 39 +++++++++-
.../org/apache/calcite/test/SqlValidatorTest.java | 86 ++++++++++++++++++++++
core/src/test/resources/sql/struct.iq | 52 +++++++++++++
4 files changed, 180 insertions(+), 5 deletions(-)
diff --git a/core/src/main/java/org/apache/calcite/sql/fun/SqlRowOperator.java
b/core/src/main/java/org/apache/calcite/sql/fun/SqlRowOperator.java
index f73a3bceb7..eaa77f03d5 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlRowOperator.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlRowOperator.java
@@ -63,9 +63,15 @@ public SqlRowOperator(String name) {
this(name, null);
}
+ /** Returns the explicit field-name aliases, or null if none were specified
+ * (in which case names are auto-generated as {@code EXPR$0}, etc.). */
+ public @Nullable List<@Nullable String> getFieldNames() {
+ return fieldNames;
+ }
+
/** Constructor for a named ROW operator with explicit field-name aliases.
* Field names may be null, in which case they are auto-generated. */
- public SqlRowOperator(String name, @Nullable List<@Nullable String>
fieldNames) {
+ public SqlRowOperator(String name, @Nullable List<? extends @Nullable
String> fieldNames) {
super(name,
SqlKind.ROW, MDX_PRECEDENCE,
false,
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 e2762d7926..12b1b27335 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
@@ -95,6 +95,7 @@
import org.apache.calcite.sql.TableCharacteristic;
import org.apache.calcite.sql.fun.SqlCase;
import org.apache.calcite.sql.fun.SqlInternalOperators;
+import org.apache.calcite.sql.fun.SqlRowOperator;
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
import org.apache.calcite.sql.parser.SqlParserPos;
import org.apache.calcite.sql.type.AssignableOperandTypeChecker;
@@ -7684,7 +7685,16 @@ private SqlNode expandStarInRow(SqlNode node) {
}
final SelectScope selectScope = (SelectScope) scope;
final List<SqlNode> expandedOperands = new ArrayList<>();
+ final List<@Nullable String> expandedNames = new ArrayList<>();
boolean expanded = false;
+
+ // Retrieve field names stored in the operator (from ROW(v AS name, ...)
syntax).
+ final @Nullable List<@Nullable String> origFieldNames =
+ call.getOperator() instanceof SqlRowOperator
+ ? ((SqlRowOperator) call.getOperator()).getFieldNames()
+ : null;
+
+ int origIdx = 0;
for (SqlNode operand : call.getOperandList()) {
final SqlIdentifier starId;
if (operand instanceof SqlStarExclude) {
@@ -7695,6 +7705,7 @@ private SqlNode expandStarInRow(SqlNode node) {
starId = null;
}
if (starId != null) {
+ final int sizeBefore = expandedOperands.size();
final boolean expandedStar =
validator.expandStar(expandedOperands,
validator.catalogReader.nameMatcher().createSet(),
@@ -7706,16 +7717,36 @@ private SqlNode expandStarInRow(SqlNode node) {
if (!expandedStar) {
throw new AssertionError("Row star expansion failed for " +
starId);
}
+ // Each newly added operand is a SqlIdentifier; its last name
component
+ // is the original column name.
+ for (int i = sizeBefore; i < expandedOperands.size(); i++) {
+ expandedNames.add(SqlValidatorUtil.alias(expandedOperands.get(i)));
+ }
expanded = true;
- continue;
+ } else {
+ expandedOperands.add(operand);
+ // Prefer the name from the original named-ROW operator; fall back to
+ // whatever alias can be derived from the operand expression itself.
+ final @Nullable String name =
+ origFieldNames != null && origIdx < origFieldNames.size()
+ && origFieldNames.get(origIdx) != null
+ ? origFieldNames.get(origIdx)
+ : SqlValidatorUtil.alias(operand);
+ expandedNames.add(name);
}
- expandedOperands.add(operand);
+ origIdx++;
}
if (!expanded) {
return node;
}
- return SqlStdOperatorTable.ROW.createCall(
- call.getParserPosition(), expandedOperands);
+ // Assign unique names to all fields. The first occurrence of a name
keeps
+ // it as-is; subsequent duplicates get a compiler-generated suffix based
on
+ // the original column name (e.g. a second DEPTNO becomes DEPTNO0).
+ final boolean caseSensitive =
validator.catalogReader.nameMatcher().isCaseSensitive();
+ final List<String> uniqueNames =
+ SqlValidatorUtil.uniquify(expandedNames,
SqlValidatorUtil.EXPR_SUGGESTER, caseSensitive);
+ return new SqlRowOperator("ROW", uniqueNames)
+ .createCall(call.getParserPosition(), expandedOperands);
}
protected SqlNode expandDynamicStar(SqlIdentifier id, SqlIdentifier fqId) {
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 0fd03a456b..0e9c533dbd 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -2197,6 +2197,76 @@ void testLikeAndSimilarFails() {
.fails("ROW\\(\\* EXCLUDE/EXCEPT list\\) cannot exclude all columns");
}
+ /** Test case for
+ * <a
href="https://issues.apache.org/jira/browse/CALCITE-7602">[CALCITE-7602]
+ * ROW(*) loses column names</a>. */
+ @Test void testRowWildcardColumnNames() {
+ // ROW(*) should produce a struct with the original column names
+ sql("select row(*) from emp")
+ .columnType(EMP_RECORD_TYPE);
+ // ROW(T.*) should also preserve column names
+ sql("select row(emp.*) from emp")
+ .columnType(EMP_RECORD_TYPE);
+ // ROW(col, T.*) where the star repeats a column already named: the
duplicate
+ // gets a compiler-assigned suffix (EMPNO0) rather than losing all names
+ sql("select row(empno, emp.*) from emp")
+ .columnType("RecordType(INTEGER NOT NULL EMPNO,"
+ + " INTEGER NOT NULL EMPNO0,"
+ + " VARCHAR(20) NOT NULL ENAME,"
+ + " VARCHAR(10) NOT NULL JOB,"
+ + " INTEGER MGR,"
+ + " TIMESTAMP(0) NOT NULL HIREDATE,"
+ + " INTEGER NOT NULL SAL,"
+ + " INTEGER NOT NULL COMM,"
+ + " INTEGER NOT NULL DEPTNO,"
+ + " BOOLEAN NOT NULL SLACKER) NOT NULL");
+ // ROW(T1.*, T2.*) with a shared column name (DEPTNO): the second DEPTNO
+ // gets the suffix DEPTNO0 while all other names are preserved
+ sql("select row(emp.*, dept.*) from emp join dept on emp.deptno =
dept.deptno")
+ .columnType("RecordType(INTEGER NOT NULL EMPNO,"
+ + " VARCHAR(20) NOT NULL ENAME,"
+ + " VARCHAR(10) NOT NULL JOB,"
+ + " INTEGER MGR,"
+ + " TIMESTAMP(0) NOT NULL HIREDATE,"
+ + " INTEGER NOT NULL SAL,"
+ + " INTEGER NOT NULL COMM,"
+ + " INTEGER NOT NULL DEPTNO,"
+ + " BOOLEAN NOT NULL SLACKER,"
+ + " INTEGER NOT NULL DEPTNO0,"
+ + " VARCHAR(10) NOT NULL NAME) NOT NULL");
+ // ROW(* EXCLUDE(col)) should preserve names of the remaining columns
+ sql("select row(* exclude(empno)) from emp")
+ .columnType("RecordType(VARCHAR(20) NOT NULL ENAME,"
+ + " VARCHAR(10) NOT NULL JOB,"
+ + " INTEGER MGR,"
+ + " TIMESTAMP(0) NOT NULL HIREDATE,"
+ + " INTEGER NOT NULL SAL,"
+ + " INTEGER NOT NULL COMM,"
+ + " INTEGER NOT NULL DEPTNO,"
+ + " BOOLEAN NOT NULL SLACKER) NOT NULL");
+ // ROW(T.* EXCLUDE(T.col)) should preserve names of the remaining columns
+ sql("select row(emp.* exclude(emp.empno)) from emp")
+ .columnType("RecordType(VARCHAR(20) NOT NULL ENAME,"
+ + " VARCHAR(10) NOT NULL JOB,"
+ + " INTEGER MGR,"
+ + " TIMESTAMP(0) NOT NULL HIREDATE,"
+ + " INTEGER NOT NULL SAL,"
+ + " INTEGER NOT NULL COMM,"
+ + " INTEGER NOT NULL DEPTNO,"
+ + " BOOLEAN NOT NULL SLACKER) NOT NULL");
+ // Named field combined with star expansion: explicit name takes precedence
+ sql("select row(empno as eno, emp.* exclude(emp.empno)) from emp")
+ .columnType("RecordType(INTEGER NOT NULL ENO,"
+ + " VARCHAR(20) NOT NULL ENAME,"
+ + " VARCHAR(10) NOT NULL JOB,"
+ + " INTEGER MGR,"
+ + " TIMESTAMP(0) NOT NULL HIREDATE,"
+ + " INTEGER NOT NULL SAL,"
+ + " INTEGER NOT NULL COMM,"
+ + " INTEGER NOT NULL DEPTNO,"
+ + " BOOLEAN NOT NULL SLACKER) NOT NULL");
+ }
+
/** Test case for
* <a
href="https://issues.apache.org/jira/browse/CALCITE-7603">[CALCITE-7603]
* Support ROW constructors that name fields</a>. */
@@ -2226,6 +2296,22 @@ void testLikeAndSimilarFails() {
.columnType("INTEGER NOT NULL");
sql("select t.a.\"EXPR$1\" from (select row(1,2) as a from (values (1)))
as t")
.columnType("INTEGER NOT NULL");
+ // After star expansion the ROW carries real column names, so field access
+ // by original name works without quoting EXPR$N ordinals.
+ sql("select row(*).empno from emp")
+ .columnType("INTEGER NOT NULL");
+ sql("select row(emp.*).ename from emp")
+ .columnType("VARCHAR(20) NOT NULL");
+ sql("select row(* exclude(empno)).ename from emp")
+ .columnType("VARCHAR(20) NOT NULL");
+ // When two stars produce a duplicate name the second gets a suffix;
+ // both the original and the suffixed name are accessible.
+ sql("select row(emp.*, dept.*).deptno from emp"
+ + " join dept on emp.deptno = dept.deptno")
+ .columnType("INTEGER NOT NULL");
+ sql("select row(emp.*, dept.*).deptno0 from emp"
+ + " join dept on emp.deptno = dept.deptno")
+ .columnType("INTEGER NOT NULL");
}
@Test void testRowWithInvalidDotOperation() {
diff --git a/core/src/test/resources/sql/struct.iq
b/core/src/test/resources/sql/struct.iq
index 9f2f393937..bf25d4cadb 100644
--- a/core/src/test/resources/sql/struct.iq
+++ b/core/src/test/resources/sql/struct.iq
@@ -285,4 +285,56 @@ select row(row(1 as x, 2 as y) as inner_row, 'hello' as
name).inner_row.x;
!ok
+# [CALCITE-7602] ROW(*), ROW(T.*) and ROW(T.* EXCLUDE(cols)) preserve original
column names
+
+# Field access by original column name on ROW(*)
+select row(*).empno from emp order by empno limit 3;
++-------+
+| EMPNO |
++-------+
+| 7369 |
+| 7499 |
+| 7521 |
++-------+
+(3 rows)
+
+!ok
+
+# Field access by original column name on ROW(T.*)
+select row(emp.*).ename from emp order by empno limit 3;
++-------+
+| ENAME |
++-------+
+| SMITH |
+| ALLEN |
+| WARD |
++-------+
+(3 rows)
+
+!ok
+
+# Field access on ROW(* EXCLUDE(col)) using a preserved name
+select row(* exclude(empno)).ename from emp order by empno limit 3;
++-------+
+| ENAME |
++-------+
+| SMITH |
+| ALLEN |
+| WARD |
++-------+
+(3 rows)
+
+!ok
+
+# ROW(T1.*, T2.*): shared column DEPTNO is renamed to DEPTNO0 for the second
table
+select row(emp.*, dept.*).deptno0 from emp join dept on emp.deptno =
dept.deptno order by emp.empno limit 1;
++---------+
+| DEPTNO0 |
++---------+
+| 20 |
++---------+
+(1 row)
+
+!ok
+
# End struct.iq