From 839ebf576338988a2984e11e10c6f771b9840a96 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Fri, 12 Jun 2026 20:04:46 -0700 Subject: [PATCH 1/2] [CALCITE-7603] Support ROW constructors that name fields Signed-off-by: Mihai Budiu --- core/src/main/codegen/templates/Parser.jj | 123 +++++++++++++++--- .../calcite/sql/fun/SqlRowOperator.java | 89 +++++++++++-- .../calcite/sql/parser/CoreSqlParserTest.java | 21 +++ .../apache/calcite/test/SqlValidatorTest.java | 26 ++++ core/src/test/resources/sql/struct.iq | 46 +++++++ 5 files changed, 277 insertions(+), 28 deletions(-) diff --git a/core/src/main/codegen/templates/Parser.jj b/core/src/main/codegen/templates/Parser.jj index d4b589d6c1a7..b340415b958c 100644 --- a/core/src/main/codegen/templates/Parser.jj +++ b/core/src/main/codegen/templates/Parser.jj @@ -109,6 +109,7 @@ import org.apache.calcite.sql.SqlWithItem; import org.apache.calcite.sql.fun.SqlCase; import org.apache.calcite.sql.fun.SqlInternalOperators; import org.apache.calcite.sql.fun.SqlLibraryOperators; +import org.apache.calcite.sql.fun.SqlRowOperator; import org.apache.calcite.sql.fun.SqlStdOperatorTable; import org.apache.calcite.sql.fun.SqlTrimFunction; import org.apache.calcite.sql.parser.Span; @@ -2703,12 +2704,14 @@ void AddRowConstructor(List list) : /** * Parses a row constructor in the context of a VALUES expression. + * Supports optional field-name aliases: {@code ROW(expr AS fieldName, ...)}. */ SqlNode RowConstructor() : { - final SqlNodeList valueList; + SqlNodeList valueList; final SqlNode value; final Span s; + final List nameList = new ArrayList(); } { // hints are necessary here due to common LPAREN prefixes @@ -2720,15 +2723,24 @@ SqlNode RowConstructor() : { s = span(); } valueList = ParenthesizedQueryOrCommaListWithDefault(ExprContext.ACCEPT_NONCURSOR) - { s.add(this); } + + { + s.add(this); + return buildRowCall(s.end(valueList), valueList, null); + } | + // Standard forms: ROW(e1 [AS n1], e2, ...) or (e1 [AS n1], e2, ...) LOOKAHEAD(3) ( { s = span(); } | { s = Span.of(); } ) - valueList = ParenthesizedQueryOrCommaListWithDefault(ExprContext.ACCEPT_NONCURSOR) + { nameList.clear(); } + valueList = RowArgListWithParens(ExprContext.ACCEPT_NONCURSOR, nameList) + { + return buildRowCall(s.end(valueList), valueList, nameList); + } | value = Expression(ExprContext.ACCEPT_NONCURSOR) { @@ -2741,15 +2753,9 @@ SqlNode RowConstructor() : s = Span.of(value); valueList = new SqlNodeList(ImmutableList.of(value), value.getParserPosition()); + return buildRowCall(s.end(valueList), valueList, null); } ) - { - // REVIEW jvs 8-Feb-2004: Should we discriminate between scalar - // sub-queries inside of ROW and row sub-queries? The standard does, - // but the distinction seems to be purely syntactic. - return SqlStdOperatorTable.ROW.createCall(s.end(valueList), - (List) valueList); - } } /** Parses a WHERE clause for SELECT, DELETE, and UPDATE. */ @@ -4173,6 +4179,27 @@ SqlKind comp() : } } +/** + * Builds a ROW call from parsed expressions and optional field-name aliases. + * Uses the singleton ROW operator when all names are absent, and a per-instance + * SqlRowOperator (carrying the names) when any AS alias was written. + */ +JAVACODE SqlNode buildRowCall(SqlParserPos pos, SqlNodeList exprList, List nameList) { + if (nameList != null) { + for (int i = 0; i < nameList.size(); i++) { + if (nameList.get(i) != null) { + final List fieldNames = new ArrayList(); + for (int j = 0; j < nameList.size(); j++) { + SqlNode n = nameList.get(j); + fieldNames.add(n instanceof SqlLiteral ? ((SqlLiteral) n).getValueAs(String.class) : null); + } + return new SqlRowOperator("ROW", fieldNames).createCall(pos, (List) exprList.getList()); + } + } + } + return SqlStdOperatorTable.ROW.createCall(pos, (List) exprList.getList()); +} + /** * Parses a unary row expression, or a parenthesized expression of any * kind. @@ -4184,6 +4211,9 @@ SqlNode Expression3(ExprContext exprContext) : final SqlNodeList list1; final Span s; final Span rowSpan; + // Populated by RowArgListWithParens: one entry per expression, either a + // SqlLiteral string for an AS-aliased field name, or null if unnamed. + final List rowFieldNames = new ArrayList(); } { LOOKAHEAD(2) @@ -4200,7 +4230,7 @@ SqlNode Expression3(ExprContext exprContext) : s = span(); pushRowValueStar(); } - list = ParenthesizedQueryOrCommaList(exprContext) { + list = RowArgListWithParens(exprContext, rowFieldNames) { try { if (exprContext != ExprContext.ACCEPT_ALL && exprContext != ExprContext.ACCEPT_CURSOR @@ -4209,7 +4239,7 @@ SqlNode Expression3(ExprContext exprContext) : throw SqlUtil.newContextException(s.end(list), RESOURCE.illegalRowExpression()); } - return SqlStdOperatorTable.ROW.createCall(list); + return buildRowCall(list.getParserPosition(), list, rowFieldNames); } finally { popRowValueStar(); } @@ -4219,12 +4249,11 @@ SqlNode Expression3(ExprContext exprContext) : { rowSpan = span(); pushRowValueStar(); } | { rowSpan = null; } ) - list1 = ParenthesizedQueryOrCommaList(exprContext) { + list1 = RowArgListWithParens(exprContext, rowFieldNames) { try { if (rowSpan != null) { // interpret as row constructor - return SqlStdOperatorTable.ROW.createCall(rowSpan.end(list1), - (List) list1); + return buildRowCall(rowSpan.end(list1), list1, rowFieldNames); } } finally { if (rowSpan != null) { @@ -4280,10 +4309,68 @@ SqlNode Expression3(ExprContext exprContext) : return list1.get(0).clone(list1.getParserPosition()); } else { // interpret as row constructor - return SqlStdOperatorTable.ROW.createCall(span().end(list1), - (List) list1); + return buildRowCall(span().end(list1), list1, rowFieldNames); + } + } +} + +/** + * Parses a parenthesized comma list for ROW constructors. + * Supports optional field-name aliases: {@code (expr AS fieldName, ...)}. + * Populates {@code nameList} with field names (or null for unnamed fields). + * Returns a SqlNodeList of the value expressions. + */ +SqlNodeList RowArgListWithParens(ExprContext exprContext, List nameList) : +{ + SqlNode e; + SqlIdentifier alias; + final List exprList = new ArrayList(); + ExprContext firstExprContext = exprContext; + final Span s; +} +{ + + { + s = span(); + switch (exprContext) { + case ACCEPT_SUB_QUERY: + firstExprContext = ExprContext.ACCEPT_NONCURSOR; + break; + case ACCEPT_CURSOR: + firstExprContext = ExprContext.ACCEPT_ALL; + break; } } + ( + e = OrderedQueryOrExpr(firstExprContext) + ( + alias = SimpleIdentifier() + { exprList.add(e); nameList.add(SqlLiteral.createCharString(alias.getSimple(), alias.getParserPosition())); } + | + { exprList.add(e); nameList.add(null); } + ) + | + e = Default() { exprList.add(e); nameList.add(null); } + ) + ( + + { + checkNonQueryExpression(exprContext); + } + ( + e = Expression(exprContext) + ( + alias = SimpleIdentifier() + { exprList.add(e); nameList.add(SqlLiteral.createCharString(alias.getSimple(), alias.getParserPosition())); } + | + { exprList.add(e); nameList.add(null); } + ) + | + e = Default() { exprList.add(e); nameList.add(null); } + ) + )* + + { return new SqlNodeList(exprList, s.end(this)); } } /** @@ -5407,7 +5494,7 @@ SqlNode PeriodConstructor() : AddExpression(args, ExprContext.ACCEPT_SUB_QUERY) { - return SqlStdOperatorTable.ROW.createCall(s.end(this), args); + return buildRowCall(s.end(this), new SqlNodeList(args, s.end(this)), null); } } 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 b20d38176525..f73a3bceb7a3 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 @@ -20,29 +20,63 @@ import org.apache.calcite.rel.type.RelDataTypeFactory; import org.apache.calcite.sql.SqlCall; import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.SqlNode; import org.apache.calcite.sql.SqlOperatorBinding; import org.apache.calcite.sql.SqlSpecialOperator; import org.apache.calcite.sql.SqlUtil; import org.apache.calcite.sql.SqlWriter; import org.apache.calcite.sql.type.InferTypes; import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.validate.SqlValidator; +import org.apache.calcite.sql.validate.SqlValidatorScope; +import org.apache.calcite.util.ImmutableNullableList; + +import org.checkerframework.checker.nullness.qual.Nullable; + +import java.util.List; /** - * SqlRowOperator represents the special ROW constructor. + * SqlRowOperator represents the special ROW constructor, {@code ROW(v1, v2, ...)}. * - *

TODO: describe usage for row-value construction and row-type construction - * (SQL supports both). + *

Fields may be given explicit names using AS aliases: + * {@code ROW(v1 AS f1, v2 AS f2, ...)}. When aliases are present, a + * per-instance operator (rather than the singleton {@link + * org.apache.calcite.sql.fun.SqlStdOperatorTable#ROW}) is used to carry the + * field names through type inference. After type inference the resulting + * {@link org.apache.calcite.rel.type.RelDataType} carries the names, so + * downstream code does not need to inspect the operator. + * + *

When no aliases are given, field names are auto-generated + * ({@code EXPR$0}, {@code EXPR$1}, …). */ public class SqlRowOperator extends SqlSpecialOperator { - //~ Constructors ----------------------------------------------------------- + /** + * Optional explicit field names. When null, field names are auto-generated + * ({@code EXPR$0}, {@code EXPR$1}, …). Individual entries may be null to + * mix named and unnamed fields. + */ + private final @Nullable List<@Nullable String> fieldNames; + + /** Constructor for the singleton (no field-name aliases). */ public SqlRowOperator(String name) { + this(name, null); + } + + /** 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) { super(name, SqlKind.ROW, MDX_PRECEDENCE, false, null, InferTypes.RETURN_TYPE, OperandTypes.VARIADIC); + if (fieldNames == null) { + this.fieldNames = null; + } else { + this.fieldNames = ImmutableNullableList.copyOf(fieldNames); + } } //~ Methods ---------------------------------------------------------------- @@ -50,13 +84,16 @@ public SqlRowOperator(String name) { @Override public RelDataType inferReturnType( final SqlOperatorBinding opBinding) { // The type of a ROW(e1,e2) expression is a record with the types - // {e1type,e2type}. According to the standard, field names are - // implementation-defined. + // ROW(e1type,e2type). Field names come from AS aliases when present; + // otherwise they are implementation-defined. final RelDataTypeFactory typeFactory = opBinding.getTypeFactory(); final RelDataTypeFactory.Builder builder = typeFactory.builder(); - for (int index = 0; index < opBinding.getOperandCount(); index++) { - builder.add(SqlUtil.deriveAliasFromOrdinal(index), - opBinding.getOperandType(index)); + for (int i = 0; i < opBinding.getOperandCount(); i++) { + final String fieldName = + fieldNames != null && fieldNames.get(i) != null + ? fieldNames.get(i) + : SqlUtil.deriveAliasFromOrdinal(i); + builder.add(fieldName, opBinding.getOperandType(i)); } final RelDataType recordType = builder.build(); @@ -68,12 +105,44 @@ public SqlRowOperator(String name) { return typeFactory.createTypeWithNullability(recordType, nullable); } + @Override public RelDataType deriveType( + SqlValidator validator, + SqlValidatorScope scope, + SqlCall call) { + if (fieldNames == null) { + return super.deriveType(validator, scope, call); + } + // For named ROW: validate operand types without replacing this operator + // via lookupRoutine (which would substitute the singleton and lose field names). + for (SqlNode operand : call.getOperandList()) { + validator.deriveType(scope, operand); + } + return validateOperands(validator, scope, call); + } + @Override public void unparse( SqlWriter writer, SqlCall call, int leftPrec, int rightPrec) { - SqlUtil.unparseFunctionSyntax(this, writer, call, false); + if (fieldNames == null) { + SqlUtil.unparseFunctionSyntax(this, writer, call, false); + return; + } + writer.print("ROW"); + writer.setNeedWhitespace(false); + final SqlWriter.Frame frame = + writer.startList(SqlWriter.FrameTypeEnum.FUN_CALL, "(", ")"); + for (int i = 0; i < call.operandCount(); i++) { + writer.sep(","); + call.operand(i).unparse(writer, 0, 0); + final String name = fieldNames.get(i); + if (name != null) { + writer.keyword("AS"); + writer.identifier(name, true); + } + } + writer.endList(frame); } // override SqlOperator diff --git a/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java b/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java index 1c90c9484dea..6b86c4bbc3f2 100644 --- a/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java +++ b/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java @@ -72,6 +72,27 @@ private boolean isNotSubclass() { return this.getClass().equals(CoreSqlParserTest.class); } + /** Test case for + * [CALCITE-7603] + * Support ROW constructors that name fields. */ + @Test void testRowWithFieldNames() { + // All fields named + sql("select row(1 as a, 'hello' as b) from emp") + .ok("SELECT (ROW(1 AS `A`, 'hello' AS `B`))\nFROM `EMP`"); + // Mixed: some fields named, some not + sql("select row(1 as a, 2) from emp") + .ok("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`"); + // No field names (existing behavior unchanged) + sql("select row(1, 2) from emp") + .ok("SELECT (ROW(1, 2))\nFROM `EMP`"); + // Expression with AS + sql("select row(empno + 1 as eno, ename as en) from emp") + .ok("SELECT (ROW((`EMPNO` + 1) AS `ENO`, `ENAME` AS `EN`))\nFROM `EMP`"); + // Round-trip: the canonical form can be re-parsed + final SqlParserFixture f = fixture().withConfig(c -> c.withQuoting(Quoting.BACK_TICK)); + f.sql("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`").same(); + } + /** Test case for * [CALCITE-7364] * Support the syntax ROW(T.* EXCLUDE cols) for creating nested ROW values. */ 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 0f40dfbc6dfd..a1439de82cc3 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,32 @@ void testLikeAndSimilarFails() { .fails("ROW\\(\\* EXCLUDE/EXCEPT list\\) cannot exclude all columns"); } + /** Test case for + * [CALCITE-7603] + * Support ROW constructors that name fields. */ + @Test void testRowWithFieldNames() { + // All fields named: the returned type should use the specified names + sql("select row(1 as a, 'hello' as b) from emp") + .columnType( + "RecordType(INTEGER NOT NULL A, CHAR(5) NOT NULL B) NOT NULL"); + // Mixed: named and unnamed fields + sql("select row(empno as eno, ename) from emp") + .columnType( + "RecordType(INTEGER NOT NULL ENO, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); + // No names: existing auto-generated behavior unchanged + sql("select row(empno, ename) from emp") + .columnType( + "RecordType(INTEGER NOT NULL EXPR$0, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); + // Access a named field by name + sql("select row(empno as eno, ename as en).eno from emp") + .columnType("INTEGER NOT NULL"); + // Nested ROW with named fields + sql("select row(row(1 as x, 2 as y) as inner_row) from emp") + .columnType( + "RecordType(RecordType(INTEGER NOT NULL X, INTEGER NOT NULL Y)" + + " NOT NULL INNER_ROW) NOT NULL"); + } + @Test void testRowWithValidDot() { sql("select ((1,2),(3,4,5)).\"EXPR$1\".\"EXPR$2\"\n from dept") .columnType("INTEGER NOT NULL"); diff --git a/core/src/test/resources/sql/struct.iq b/core/src/test/resources/sql/struct.iq index 1d894d254e18..198ec492ae1e 100644 --- a/core/src/test/resources/sql/struct.iq +++ b/core/src/test/resources/sql/struct.iq @@ -238,4 +238,50 @@ select row(emp.* exclude(emp.empno), dept.* exclude(dept.deptno)) from emp join !ok +# [CALCITE-7603] Support ROW constructors that name fields +select row(1 as a, 'hello' as b); ++------------+ +| EXPR$0 | ++------------+ +| {1, hello} | ++------------+ +(1 row) + +!ok + +# Named field access: .field selects a field from a named-field ROW +select row(1 as a, 'hello' as b).a; ++--------+ +| EXPR$0 | ++--------+ +| 1 | ++--------+ +(1 row) + +!ok + +# Named field access on a column expression +select row(empno as eno, ename as en).eno from emp order by empno limit 3; ++--------+ +| EXPR$0 | ++--------+ +| 7369 | +| 7499 | +| 7521 | ++--------+ +(3 rows) + +!ok + +# Nested named-field ROW, and access to inner field +select row(row(1 as x, 2 as y) as inner_row, 'hello' as name).inner_row.x; ++--------+ +| EXPR$0 | ++--------+ +| 1 | ++--------+ +(1 row) + +!ok + # End struct.iq From d39f372ec7dfd56bae5079f5c07b19724519e388 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Sat, 13 Jun 2026 15:24:40 -0700 Subject: [PATCH 2/2] Added documentation; moved tests to a different file; added a new negative test Signed-off-by: Mihai Budiu --- .../calcite/sql/parser/CoreSqlParserTest.java | 67 ------------------ .../apache/calcite/test/SqlValidatorTest.java | 12 ++-- site/_docs/reference.md | 22 +++--- .../calcite/sql/parser/SqlParserTest.java | 69 +++++++++++++++++++ 4 files changed, 84 insertions(+), 86 deletions(-) diff --git a/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java b/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java index 6b86c4bbc3f2..c8ad180009ff 100644 --- a/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java +++ b/core/src/test/java/org/apache/calcite/sql/parser/CoreSqlParserTest.java @@ -16,7 +16,6 @@ */ package org.apache.calcite.sql.parser; -import org.apache.calcite.avatica.util.Quoting; import org.apache.calcite.test.DiffTestCase; import com.google.common.collect.ImmutableList; @@ -71,70 +70,4 @@ public class CoreSqlParserTest extends SqlParserTest { private boolean isNotSubclass() { return this.getClass().equals(CoreSqlParserTest.class); } - - /** Test case for - * [CALCITE-7603] - * Support ROW constructors that name fields. */ - @Test void testRowWithFieldNames() { - // All fields named - sql("select row(1 as a, 'hello' as b) from emp") - .ok("SELECT (ROW(1 AS `A`, 'hello' AS `B`))\nFROM `EMP`"); - // Mixed: some fields named, some not - sql("select row(1 as a, 2) from emp") - .ok("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`"); - // No field names (existing behavior unchanged) - sql("select row(1, 2) from emp") - .ok("SELECT (ROW(1, 2))\nFROM `EMP`"); - // Expression with AS - sql("select row(empno + 1 as eno, ename as en) from emp") - .ok("SELECT (ROW((`EMPNO` + 1) AS `ENO`, `ENAME` AS `EN`))\nFROM `EMP`"); - // Round-trip: the canonical form can be re-parsed - final SqlParserFixture f = fixture().withConfig(c -> c.withQuoting(Quoting.BACK_TICK)); - f.sql("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`").same(); - } - - /** Test case for - * [CALCITE-7364] - * Support the syntax ROW(T.* EXCLUDE cols) for creating nested ROW values. */ - @Test void testRowStarExclude() { - // Use backticks to ensure that sql(q).same() in general - final SqlParserFixture f = fixture().withConfig(c -> c.withQuoting(Quoting.BACK_TICK)); - final String empExcludeEmpno = "SELECT (ROW(`EMP`.* EXCLUDE (`EMP`.`EMPNO`)))\n" - + "FROM `EMP`"; - - // Simple star with one excluded column - final String starExcludeEmpno = "SELECT (ROW(* EXCLUDE (`EMPNO`)))\n" - + "FROM `EMP`"; - sql("select row(* exclude(empno)) from emp").ok(starExcludeEmpno); - f.sql(starExcludeEmpno).same(); - - // Table-qualified star with excluded column - sql("select row(emp.* exclude(emp.empno)) from emp").ok(empExcludeEmpno); - f.sql(empExcludeEmpno).same(); - - // EXCEPT is normalized to EXCLUDE on unparse - sql("select row(emp.* except(emp.empno)) from emp").ok(empExcludeEmpno); - - // Multiple excluded columns - final String starExcludeEmpnoMgr = "SELECT (ROW(* EXCLUDE (`EMPNO`, `MGR`)))\n" - + "FROM `EMP`"; - sql("select row(* exclude(empno, mgr)) from emp").ok(starExcludeEmpnoMgr); - f.sql(starExcludeEmpnoMgr).same(); - - // Mixed: table-qualified star with exclude, plus plain star - final String empExcludeEmpnoDeptStar = - "SELECT (ROW(`EMP`.* EXCLUDE (`EMP`.`EMPNO`), `DEPT`.*))\n" - + "FROM `EMP`\n" - + "INNER JOIN `DEPT` ON (`EMP`.`DEPTNO` = `DEPT`.`DEPTNO`)"; - sql("select row(emp.* exclude(emp.empno), dept.*)" - + " from emp join dept on emp.deptno = dept.deptno") - .ok(empExcludeEmpnoDeptStar); - f.sql(empExcludeEmpnoDeptStar).same(); - - // Nested ROW with EXCLUDE - final String nestedStarExcludeEmpno = "SELECT (ROW((ROW(* EXCLUDE (`EMPNO`)))))\n" - + "FROM `EMP`"; - sql("select row(row(* exclude(empno))) from emp").ok(nestedStarExcludeEmpno); - f.sql(nestedStarExcludeEmpno).same(); - } } 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 a1439de82cc3..f473cf60db37 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java @@ -2203,23 +2203,19 @@ void testLikeAndSimilarFails() { @Test void testRowWithFieldNames() { // All fields named: the returned type should use the specified names sql("select row(1 as a, 'hello' as b) from emp") - .columnType( - "RecordType(INTEGER NOT NULL A, CHAR(5) NOT NULL B) NOT NULL"); + .columnType("RecordType(INTEGER NOT NULL A, CHAR(5) NOT NULL B) NOT NULL"); // Mixed: named and unnamed fields sql("select row(empno as eno, ename) from emp") - .columnType( - "RecordType(INTEGER NOT NULL ENO, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); + .columnType("RecordType(INTEGER NOT NULL ENO, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); // No names: existing auto-generated behavior unchanged sql("select row(empno, ename) from emp") - .columnType( - "RecordType(INTEGER NOT NULL EXPR$0, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); + .columnType("RecordType(INTEGER NOT NULL EXPR$0, VARCHAR(20) NOT NULL EXPR$1) NOT NULL"); // Access a named field by name sql("select row(empno as eno, ename as en).eno from emp") .columnType("INTEGER NOT NULL"); // Nested ROW with named fields sql("select row(row(1 as x, 2 as y) as inner_row) from emp") - .columnType( - "RecordType(RecordType(INTEGER NOT NULL X, INTEGER NOT NULL Y)" + .columnType("RecordType(RecordType(INTEGER NOT NULL X, INTEGER NOT NULL Y)" + " NOT NULL INNER_ROW) NOT NULL"); } diff --git a/site/_docs/reference.md b/site/_docs/reference.md index 026fa59c4741..e546757d0bce 100644 --- a/site/_docs/reference.md +++ b/site/_docs/reference.md @@ -1797,17 +1797,17 @@ Implicit type coercion of following cases are ignored: ### Value constructors -| Operator syntax | Description -|:--------------- |:----------- -| ROW (value [, value ]*) | Creates a row from a list of values. -| ROW (rowStarItem [, rowStarItem ]*) | Creates a row from all columns, or all columns except those excluded, of one or more tables. -| (value [, value ]* ) | Creates a row from a list of values. -| row '[' index ']' | Returns the element at a particular location in a row (1-based index). -| row '[' name ']' | Returns the element of a row with a particular name. -| map '[' key ']' | Returns the element of a map with a particular key. -| array '[' index ']' | Returns the element at a particular location in an array (1-based index). -| ARRAY '[' value [, value ]* ']' | Creates an array from a list of values. -| MAP '[' key, value [, key, value ]* ']' | Creates a map from a list of key-value pairs. +| Operator syntax | Description +|:-------------------------------------------|:----------- +| ROW (value [AS name] [, value [AS name]]*) | Creates a row from a list of values. +| ROW (rowStarItem [, rowStarItem ]*) | Creates a row from all columns, or all columns except those excluded, of one or more tables. +| (value [, value ]* ) | Creates a row from a list of values. +| row '[' index ']' | Returns the element at a particular location in a row (1-based index). +| row '[' name ']' | Returns the element of a row with a particular name. +| map '[' key ']' | Returns the element of a map with a particular key. +| array '[' index ']' | Returns the element at a particular location in an array (1-based index). +| ARRAY '[' value [, value ]* ']' | Creates an array from a list of values. +| MAP '[' key, value [, key, value ]* ']' | Creates a map from a list of key-value pairs. ### Value constructors by query 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 4ab6f776ba4a..a9e3c6b95943 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 @@ -10242,4 +10242,73 @@ public void checkExpFails(String sql, String expected) { .fails(expected.replace("$op", op)); } } + + /** Test case for + * [CALCITE-7603] + * Support ROW constructors that name fields. */ + @Test void testRowWithFieldNames() { + // All fields named + sql("select row(1 as a, 'hello' as b) from emp") + .ok("SELECT (ROW(1 AS `A`, 'hello' AS `B`))\nFROM `EMP`"); + // Mixed: some fields named, some not + sql("select row(1 as a, 2) from emp") + .ok("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`"); + // No field names (existing behavior unchanged) + sql("select row(1, 2) from emp") + .ok("SELECT (ROW(1, 2))\nFROM `EMP`"); + // Expression with AS + sql("select row(empno + 1 as eno, ename as en) from emp") + .ok("SELECT (ROW((`EMPNO` + 1) AS `ENO`, `ENAME` AS `EN`))\nFROM `EMP`"); + // Round-trip: the canonical form can be re-parsed + final SqlParserFixture f = fixture().withConfig(c -> c.withQuoting(Quoting.BACK_TICK)); + f.sql("SELECT (ROW(1 AS `A`, 2))\nFROM `EMP`").same(); + // AS is not optional in ROW constructors + sql("select row(1 ^a^, 'hello' b) from emp") + .fails("(?s)Encountered \"a\" at line 1, column 14.*"); + } + + /** Test case for + * [CALCITE-7364] + * Support the syntax ROW(T.* EXCLUDE cols) for creating nested ROW values. */ + @Test void testRowStarExclude() { + // Use backticks to ensure that sql(q).same() in general + final SqlParserFixture f = fixture().withConfig(c -> c.withQuoting(Quoting.BACK_TICK)); + final String empExcludeEmpno = "SELECT (ROW(`EMP`.* EXCLUDE (`EMP`.`EMPNO`)))\n" + + "FROM `EMP`"; + + // Simple star with one excluded column + final String starExcludeEmpno = "SELECT (ROW(* EXCLUDE (`EMPNO`)))\n" + + "FROM `EMP`"; + sql("select row(* exclude(empno)) from emp").ok(starExcludeEmpno); + f.sql(starExcludeEmpno).same(); + + // Table-qualified star with excluded column + sql("select row(emp.* exclude(emp.empno)) from emp").ok(empExcludeEmpno); + f.sql(empExcludeEmpno).same(); + + // EXCEPT is normalized to EXCLUDE on unparse + sql("select row(emp.* except(emp.empno)) from emp").ok(empExcludeEmpno); + + // Multiple excluded columns + final String starExcludeEmpnoMgr = "SELECT (ROW(* EXCLUDE (`EMPNO`, `MGR`)))\n" + + "FROM `EMP`"; + sql("select row(* exclude(empno, mgr)) from emp").ok(starExcludeEmpnoMgr); + f.sql(starExcludeEmpnoMgr).same(); + + // Mixed: table-qualified star with exclude, plus plain star + final String empExcludeEmpnoDeptStar = + "SELECT (ROW(`EMP`.* EXCLUDE (`EMP`.`EMPNO`), `DEPT`.*))\n" + + "FROM `EMP`\n" + + "INNER JOIN `DEPT` ON (`EMP`.`DEPTNO` = `DEPT`.`DEPTNO`)"; + sql("select row(emp.* exclude(emp.empno), dept.*)" + + " from emp join dept on emp.deptno = dept.deptno") + .ok(empExcludeEmpnoDeptStar); + f.sql(empExcludeEmpnoDeptStar).same(); + + // Nested ROW with EXCLUDE + final String nestedStarExcludeEmpno = "SELECT (ROW((ROW(* EXCLUDE (`EMPNO`)))))\n" + + "FROM `EMP`"; + sql("select row(row(* exclude(empno))) from emp").ok(nestedStarExcludeEmpno); + f.sql(nestedStarExcludeEmpno).same(); + } }