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 c7c3f316b6 [CALCITE-7448] Add support for ':' variant path access
syntax
c7c3f316b6 is described below
commit c7c3f316b6b418714706d18a0f71480bfa5c2a24
Author: Tamas Mate <[email protected]>
AuthorDate: Fri May 8 08:07:29 2026 +0200
[CALCITE-7448] Add support for ':' variant path access syntax
Introduces opt-in ':' field/item access behind a new
SqlConformance#isColonFieldAccessAllowed() hook; no built-in conformance
enables it, so default parser behavior is unchanged.
The parser represents ':' as a dedicated SqlColonOperator (SqlKind.COLON)
with shape (base, SqlNodeList<segments>), where each segment is an
identifier (dot-notation), string literal (bracket key), or integer
literal (bracket index). The validator requires the base to be VARIANT
or ANY and types the result as nullable VARIANT. StandardConvertletTable
lowers COLON to a left-folded chain of ITEM calls; engines needing
different semantics register a convertlet for SqlKind.COLON. When colon
mode is active, JSON_OBJECT/JSON_OBJECTAGG must use the KEY...VALUE form
to avoid grammar ambiguity.
---
.../org/apache/calcite/test/BabelParserTest.java | 58 ++++++++
.../java/org/apache/calcite/test/BabelTest.java | 2 +-
core/src/main/codegen/templates/Parser.jj | 83 ++++++++++-
.../main/java/org/apache/calcite/plan/Strong.java | 1 +
.../main/java/org/apache/calcite/sql/SqlKind.java | 3 +
.../apache/calcite/sql/fun/SqlColonOperator.java | 123 ++++++++++++++++
.../calcite/sql/fun/SqlStdOperatorTable.java | 5 +
.../org/apache/calcite/sql/type/OperandTypes.java | 32 +++++
.../sql/validate/SqlAbstractConformance.java | 4 +
.../calcite/sql/validate/SqlConformance.java | 12 ++
.../calcite/sql/validate/SqlConformanceEnum.java | 4 +
.../sql/validate/SqlDelegatingConformance.java | 4 +
.../calcite/sql2rel/StandardConvertletTable.java | 18 +++
.../apache/calcite/sql/test/SqlAdvisorTest.java | 1 +
.../apache/calcite/test/SqlToRelConverterTest.java | 11 ++
.../org/apache/calcite/test/SqlValidatorTest.java | 22 +++
.../apache/calcite/test/SqlToRelConverterTest.xml | 11 ++
site/_docs/reference.md | 6 +
.../apache/calcite/sql/parser/SqlParserTest.java | 157 +++++++++++++++++++++
.../java/org/apache/calcite/test/FixtureTest.java | 2 +-
20 files changed, 555 insertions(+), 4 deletions(-)
diff --git a/babel/src/test/java/org/apache/calcite/test/BabelParserTest.java
b/babel/src/test/java/org/apache/calcite/test/BabelParserTest.java
index f458a174da..a305586738 100644
--- a/babel/src/test/java/org/apache/calcite/test/BabelParserTest.java
+++ b/babel/src/test/java/org/apache/calcite/test/BabelParserTest.java
@@ -25,6 +25,7 @@
import org.apache.calcite.sql.parser.SqlParserTest;
import org.apache.calcite.sql.parser.StringAndPos;
import org.apache.calcite.sql.parser.babel.SqlBabelParserImpl;
+import org.apache.calcite.sql.validate.SqlAbstractConformance;
import org.apache.calcite.tools.Hoist;
import com.google.common.base.Throwables;
@@ -301,6 +302,63 @@ private void checkParseInfixCast(String sqlType) {
sql(sql).ok(expected);
}
+ @Test void testPostfixAccessWithInfixCast() {
+ final SqlParserFixture f =
+ fixture().withConformance(new SqlAbstractConformance() {
+ @Override public boolean isColonFieldAccessAllowed() {
+ return true;
+ }
+ });
+
+ // Without parentheses, trailing postfixes after :: are consumed as part of
+ // the type.
+ sql("select v::varchar array[1].field from t")
+ .ok("SELECT `V` :: (VARCHAR ARRAY[1].`FIELD`)\nFROM `T`");
+ f.sql("select v:field::varchar array[1].field2 from t")
+ .ok("SELECT (`V`:`field`) :: (VARCHAR ARRAY[1].`FIELD2`)\nFROM `T`");
+
+ // Parenthesizing the cast lets the same postfixes apply to the cast
+ // result instead.
+ sql("select (v::varchar array)[1].field from t")
+ .ok("SELECT (`V` :: VARCHAR ARRAY[1].`FIELD`)\nFROM `T`");
+ f.sql("select (v:field::varchar array)[1].field2 from t")
+ .ok("SELECT ((`V`:`field`) :: VARCHAR ARRAY[1].`FIELD2`)\nFROM `T`");
+
+ // Postfix access is also accepted directly after :: in ordinary field/item
+ // chains.
+ sql("select v.field::integer,\n"
+ + " arr[1].field::varchar,\n"
+ + " v.field.field2::integer,\n"
+ + " v.field[2]::integer\n"
+ + "from t")
+ .ok("SELECT `V`.`FIELD` :: INTEGER,"
+ + " (`ARR`[1].`FIELD`) :: VARCHAR,"
+ + " `V`.`FIELD`.`FIELD2` :: INTEGER,"
+ + " `V`.`FIELD`[2] :: INTEGER\n"
+ + "FROM `T`");
+ f.sql("select v:field::integer,\n"
+ + " arr[1]:field::varchar,\n"
+ + " v:field.field2::integer,\n"
+ + " v:field[2]::integer\n"
+ + "from t")
+ .ok("SELECT (`V`:`field`) :: INTEGER,"
+ + " (`ARR`[1]:`field`) :: VARCHAR,"
+ + " (`V`:`field`.`field2`) :: INTEGER,"
+ + " (`V`:`field`[2]) :: INTEGER\n"
+ + "FROM `T`");
+
+ // Deep mixed path: dotted identifiers, string-bracket key, integer-bracket
+ // index, and a trailing infix cast in a single expression.
+ f.sql("select v:a.b['c'][0]::integer from t")
+ .ok("SELECT (`V`:`a`.`b`['c'][0]) :: INTEGER\nFROM `T`");
+ f.sql("select v:['a'].b[0]['c']::varchar from t")
+ .ok("SELECT (`V`:['a'].`b`[0]['c']) :: VARCHAR\nFROM `T`");
+
+ // Double-colon followed by colon field access is not allowed
+ f.sql("select v::variant^:^field from t")
+ .fails("(?s).*Encountered \":.*\".*");
+ }
+
/** Tests parsing MySQL-style "<=>" equal operator. */
@Test void testParseNullSafeEqual() {
// x <=> y
diff --git a/babel/src/test/java/org/apache/calcite/test/BabelTest.java
b/babel/src/test/java/org/apache/calcite/test/BabelTest.java
index 6970280ed4..aff1aee660 100644
--- a/babel/src/test/java/org/apache/calcite/test/BabelTest.java
+++ b/babel/src/test/java/org/apache/calcite/test/BabelTest.java
@@ -175,7 +175,7 @@ private void checkInfixCast(Statement statement, String
typeName, int sqlType)
// Postgres cast is invalid with core parser
p.sql("select 1 ^:^: integer as x")
- .fails("(?s).*Encountered \":\" at .*");
+ .fails("(?s).*Encountered \":[^\"]*\" at .*");
}
/** Test case for <a
href="https://issues.apache.org/jira/browse/CALCITE-7310">
diff --git a/core/src/main/codegen/templates/Parser.jj
b/core/src/main/codegen/templates/Parser.jj
index abe4c3ccec..7e54fb72f0 100644
--- a/core/src/main/codegen/templates/Parser.jj
+++ b/core/src/main/codegen/templates/Parser.jj
@@ -3794,6 +3794,83 @@ SqlNode Expression(ExprContext exprContext) :
list = Expression2(exprContext) { return SqlParserUtil.toTree(list); }
}
+/** Adds an optional trailing colon path to the expression under
+ * construction, for example {@code :field.nested}, {@code :['field']} or
+ * {@code :item[1].price}. Dot-separated path segments are parsed as simple
+ * identifiers, but unquoted segment spelling is preserved for case-sensitive
+ * variant key lookup. Bracketed segments are literals (string for exact key
+ * access, integer for array index); arbitrary expressions are not allowed. */
+void AddOptionalColonPath(List<Object> list) :
+{
+ SqlParserPos colonPos;
+ List<SqlNode> segments;
+ Span s;
+ SqlIdentifier p;
+}
+{
+ [
+ LOOKAHEAD(2, <COLON> ( SimpleIdentifier() | <LBRACKET> ),
+ { this.conformance.isColonFieldAccessAllowed() })
+ <COLON> {
+ colonPos = getPos();
+ segments = new ArrayList<SqlNode>();
+ s = span();
+ }
+ (
+ p = SimpleIdentifier() {
+ segments.add(p.getParserPosition().isQuoted()
+ ? p
+ : new SqlIdentifier(getToken(0).image,
p.getParserPosition()));
+ }
+ |
+ ColonBracketSegment(segments)
+ )
+ (
+ LOOKAHEAD(2) <DOT>
+ p = SimpleIdentifier() {
+ segments.add(p.getParserPosition().isQuoted()
+ ? p
+ : new SqlIdentifier(getToken(0).image,
p.getParserPosition()));
+ }
+ |
+ ColonBracketSegment(segments)
+ )*
+ {
+ list.add(
+ new SqlParserUtil.ToTreeListItem(
+ SqlStdOperatorTable.COLON, colonPos));
+ list.add(new SqlNodeList(segments, s.end(this)));
+ }
+ ]
+}
+
+/** Parses one bracketed segment of a colon path: {@code ['field']} for key
+ * access, or {@code [n]} for array index. */
+void ColonBracketSegment(List<SqlNode> segments) :
+{
+ SqlNode lit;
+ SqlIdentifier id;
+}
+{
+ <LBRACKET>
+ (
+ lit = StringLiteral() { segments.add(lit); }
+ |
+ <UNSIGNED_INTEGER_LITERAL> {
+ segments.add(SqlLiteral.createExactNumeric(token.image, getPos()));
+ }
+ |
+ LOOKAHEAD(SimpleIdentifier() <RBRACKET>)
+ id = SimpleIdentifier() {
+ throw SqlUtil.newContextException(id.getParserPosition(),
+ RESOURCE.unknownIdentifier(id.toString()));
+ }
+ )
+ <RBRACKET>
+}
+
+/** Parses an expression atom with its dot-access chain and at most one
+ * trailing colon path. */
void AddExpression2b(List<Object> list, ExprContext exprContext) :
{
SqlNode e;
@@ -3820,6 +3897,7 @@ void AddExpression2b(List<Object> list, ExprContext
exprContext) :
list.add(ext);
}
)*
+ AddOptionalColonPath(list)
}
/**
@@ -4005,6 +4083,7 @@ List<Object> Expression2(ExprContext exprContext) :
list.add(p);
}
)*
+ AddOptionalColonPath(list)
|
{
checkNonQueryExpression(exprContext);
@@ -7034,13 +7113,13 @@ List<SqlNode> JsonNameAndValue() :
<VALUE>
|
<COMMA> {
- if (kvMode) {
+ if (kvMode || this.conformance.isColonFieldAccessAllowed()) {
throw SqlUtil.newContextException(getPos(),
RESOURCE.illegalComma());
}
}
|
<COLON> {
- if (kvMode) {
+ if (kvMode || this.conformance.isColonFieldAccessAllowed()) {
throw SqlUtil.newContextException(getPos(),
RESOURCE.illegalColon());
}
}
diff --git a/core/src/main/java/org/apache/calcite/plan/Strong.java
b/core/src/main/java/org/apache/calcite/plan/Strong.java
index b92c98f560..ee349b837c 100644
--- a/core/src/main/java/org/apache/calcite/plan/Strong.java
+++ b/core/src/main/java/org/apache/calcite/plan/Strong.java
@@ -370,6 +370,7 @@ private static Map<SqlKind, Policy> createPolicyMap() {
map.put(SqlKind.TIMESTAMP_ADD, Policy.ANY);
map.put(SqlKind.TIMESTAMP_DIFF, Policy.ANY);
map.put(SqlKind.ITEM, Policy.ANY);
+ map.put(SqlKind.COLON, Policy.ANY);
// Assume that any other expressions cannot be simplified.
for (SqlKind k
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlKind.java
b/core/src/main/java/org/apache/calcite/sql/SqlKind.java
index d2c57864b8..40111fe0af 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlKind.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlKind.java
@@ -204,6 +204,9 @@ public enum SqlKind {
/** Item expression. */
ITEM,
+ /** Colon path access. */
+ COLON,
+
/** {@code UNION} relational operator. */
UNION,
diff --git
a/core/src/main/java/org/apache/calcite/sql/fun/SqlColonOperator.java
b/core/src/main/java/org/apache/calcite/sql/fun/SqlColonOperator.java
new file mode 100644
index 0000000000..a1ac8e5d41
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlColonOperator.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.sql.fun;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.SqlSpecialOperator;
+import org.apache.calcite.sql.SqlWriter;
+import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.type.OperandTypes;
+import org.apache.calcite.sql.util.SqlBasicVisitor;
+import org.apache.calcite.sql.util.SqlVisitor;
+import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
+import org.apache.calcite.util.Litmus;
+
+import java.util.Arrays;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * The colon operator {@code :}, used for variant path access.
+ *
+ * <p>Operands are {@code (base, path)}, where {@code path} is a
+ * {@link SqlNodeList} of segments.
+ */
+public class SqlColonOperator extends SqlSpecialOperator {
+ SqlColonOperator() {
+ super("COLON", SqlKind.COLON, 100, true, null, null, OperandTypes.COLON);
+ }
+
+ // Path segments are structural literals/identifiers, never column refs, so
+ // they must not be routed through expression visitors such as AggChecker.
+ @Override public <R> void acceptCall(SqlVisitor<R> visitor, SqlCall call,
+ boolean onlyExpressions, SqlBasicVisitor.ArgHandler<R> argHandler) {
+ if (onlyExpressions) {
+ argHandler.visitChild(visitor, call, 0, call.operand(0));
+ } else {
+ super.acceptCall(visitor, call, onlyExpressions, argHandler);
+ }
+ }
+
+ @Override public ReduceResult reduceExpr(int ordinal, TokenSequence list) {
+ final SqlNode left = list.node(ordinal - 1);
+ final SqlNode right = list.node(ordinal + 1);
+ return new ReduceResult(ordinal - 1, ordinal + 2,
+ createCall(
+ SqlParserPos.sum(
+ Arrays.asList(
+ requireNonNull(left, "left").getParserPosition(),
+ requireNonNull(right, "right").getParserPosition(),
+ list.pos(ordinal))),
+ left,
+ right));
+ }
+
+ @Override public void unparse(SqlWriter writer, SqlCall call, int leftPrec,
+ int rightPrec) {
+ final SqlWriter.Frame frame =
+ writer.startList(SqlWriter.FrameTypeEnum.IDENTIFIER);
+ call.operand(0).unparse(writer, leftPrec, 0);
+ writer.setNeedWhitespace(false);
+ writer.keyword(":");
+ writer.setNeedWhitespace(false);
+ boolean first = true;
+ for (SqlNode segment : (SqlNodeList) call.operand(1)) {
+ if (segment instanceof SqlIdentifier) {
+ if (!first) {
+ writer.sep(".");
+ }
+ segment.unparse(writer, 0, 0);
+ } else {
+ final SqlWriter.Frame b = writer.startList("[", "]");
+ segment.unparse(writer, 0, 0);
+ writer.endList(b);
+ }
+ first = false;
+ }
+ writer.endList(frame);
+ }
+
+ @Override public RelDataType deriveType(SqlValidator validator,
+ SqlValidatorScope scope, SqlCall call) {
+ // Do not derive type of operand 1; it is a path, not an expression.
+ final RelDataType baseType =
+ requireNonNull(validator.deriveType(scope, call.operand(0)));
+ final RelDataType type =
+ validator.getTypeFactory().createTypeWithNullability(baseType, true);
+ validator.setValidatedNodeType(call, type);
+ return type;
+ }
+
+ @Override public void validateCall(SqlCall call, SqlValidator validator,
+ SqlValidatorScope scope, SqlValidatorScope operandScope) {
+ assert call.getOperator() == this;
+ // Do not validate operand 1; it is a path, not an expression.
+ call.operand(0).validateExpr(validator, operandScope);
+ checkOperandTypes(new SqlCallBinding(validator, scope, call), true);
+ }
+
+ @Override public boolean validRexOperands(final int count, final Litmus
litmus) {
+ return litmus.fail("COLON is valid only for SqlCall not for RexCall");
+ }
+}
diff --git
a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
index 1137539fdc..494fafeccc 100644
--- a/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
+++ b/core/src/main/java/org/apache/calcite/sql/fun/SqlStdOperatorTable.java
@@ -2311,6 +2311,11 @@ public class SqlStdOperatorTable extends
ReflectiveSqlOperatorTable {
public static final SqlOperator ITEM =
new SqlItemOperator("ITEM", OperandTypes.ARRAY_OR_MAP_OR_VARIANT, 1,
true);
+ /**
+ * Colon operator, '<code>:</code>', used for variant path access.
+ */
+ public static final SqlOperator COLON = new SqlColonOperator();
+
/**
* The ARRAY Value Constructor. e.g. "<code>ARRAY[1, 2, 3]</code>".
*/
diff --git a/core/src/main/java/org/apache/calcite/sql/type/OperandTypes.java
b/core/src/main/java/org/apache/calcite/sql/type/OperandTypes.java
index f3c848d34e..f525975c98 100644
--- a/core/src/main/java/org/apache/calcite/sql/type/OperandTypes.java
+++ b/core/src/main/java/org/apache/calcite/sql/type/OperandTypes.java
@@ -614,6 +614,38 @@ public static SqlOperandTypeChecker variadic(
.or(OperandTypes.family(SqlTypeFamily.VARIANT))
.or(OperandTypes.family(SqlTypeFamily.ANY));
+ /**
+ * Operand type-checking strategy for the colon operator: first operand
+ * must be {@code VARIANT} (or {@code ANY}); the second operand is a
+ * path expression and is not type-checked.
+ */
+ public static final SqlOperandTypeChecker COLON =
+ new SqlOperandTypeChecker() {
+ @Override public boolean checkOperandTypes(SqlCallBinding callBinding,
+ boolean throwOnFailure) {
+ final RelDataType baseType = callBinding.getOperandType(0);
+ switch (baseType.getSqlTypeName()) {
+ case VARIANT:
+ case ANY:
+ return true;
+ default:
+ if (throwOnFailure) {
+ throw callBinding.getValidator().newValidationError(
+ callBinding.operand(0), RESOURCE.incompatibleTypes());
+ }
+ return false;
+ }
+ }
+
+ @Override public SqlOperandCountRange getOperandCountRange() {
+ return SqlOperandCountRanges.of(2);
+ }
+
+ @Override public String getAllowedSignatures(SqlOperator op, String
opName) {
+ return "<VARIANT>:<PATH>";
+ }
+ };
+
public static final SqlOperandTypeChecker
STRING_ARRAY_CHARACTER_OPTIONAL_CHARACTER =
new FamilyOperandTypeChecker(
ImmutableList.of(SqlTypeFamily.ARRAY, SqlTypeFamily.CHARACTER,
SqlTypeFamily.CHARACTER),
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
index 39799a733c..b19d8c071d 100644
---
a/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
+++
b/core/src/main/java/org/apache/calcite/sql/validate/SqlAbstractConformance.java
@@ -85,6 +85,10 @@ public abstract class SqlAbstractConformance implements
SqlConformance {
return SqlConformanceEnum.DEFAULT.isBangEqualAllowed();
}
+ @Override public boolean isColonFieldAccessAllowed() {
+ return SqlConformanceEnum.DEFAULT.isColonFieldAccessAllowed();
+ }
+
@Override public boolean isMinusAllowed() {
return SqlConformanceEnum.DEFAULT.isMinusAllowed();
}
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
index 5e652be9b8..fe902759db 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformance.java
@@ -260,6 +260,18 @@ enum SelectAliasLookup {
*/
boolean allowHyphenInUnquotedTableName();
+ /**
+ * Whether {@code :} is allowed as a field/item access operator.
+ *
+ * <p>If true, expressions such as {@code v:field} and {@code v:['field']}
+ * are accepted. In this mode, {@code JSON_OBJECT} and {@code JSON_OBJECTAGG}
+ * must use the {@code KEY ... VALUE} form rather than {@code :} or
+ * comma-pair syntax.
+ */
+ default boolean isColonFieldAccessAllowed() {
+ return false;
+ }
+
/**
* Whether the bang-equal token != is allowed as an alternative to <>
in
* the parser.
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
index 8d7d0b5306..047f05981a 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlConformanceEnum.java
@@ -232,6 +232,10 @@ public enum SqlConformanceEnum implements SqlConformance {
}
}
+ @Override public boolean isColonFieldAccessAllowed() {
+ return false;
+ }
+
@Override public boolean isBangEqualAllowed() {
switch (this) {
case LENIENT:
diff --git
a/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
b/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
index 00a77b0ee0..25f8d7e037 100644
---
a/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
+++
b/core/src/main/java/org/apache/calcite/sql/validate/SqlDelegatingConformance.java
@@ -87,6 +87,10 @@ protected SqlDelegatingConformance(SqlConformance delegate) {
return delegate.allowHyphenInUnquotedTableName();
}
+ @Override public boolean isColonFieldAccessAllowed() {
+ return delegate.isColonFieldAccessAllowed();
+ }
+
@Override public boolean isBangEqualAllowed() {
return delegate.isBangEqualAllowed();
}
diff --git
a/core/src/main/java/org/apache/calcite/sql2rel/StandardConvertletTable.java
b/core/src/main/java/org/apache/calcite/sql2rel/StandardConvertletTable.java
index 739c3e30fa..92e0739624 100644
--- a/core/src/main/java/org/apache/calcite/sql2rel/StandardConvertletTable.java
+++ b/core/src/main/java/org/apache/calcite/sql2rel/StandardConvertletTable.java
@@ -288,6 +288,8 @@ private StandardConvertletTable() {
call.operand(1).toString(), false));
// "ITEM"
registerOp(SqlStdOperatorTable.ITEM, this::convertItem);
+ // "COLON"
+ registerOp(SqlStdOperatorTable.COLON, (cx, call) -> convertColon(cx,
call));
// "AS" has no effect, so expand "x AS id" into "x".
registerOp(SqlStdOperatorTable.AS,
(cx, call) -> cx.convertExpression(call.operand(0)));
@@ -1162,6 +1164,22 @@ private RexNode convertItem(
return rexBuilder.makeCall(call.getParserPosition(), type, op,
RexUtil.flatten(exprs, op));
}
+ private RexNode convertColon(
+ @UnknownInitialization StandardConvertletTable this,
+ SqlRexContext cx,
+ SqlCall call) {
+ final RexBuilder rexBuilder = cx.getRexBuilder();
+ RexNode result = cx.convertExpression(call.operand(0));
+ final SqlNodeList path = (SqlNodeList) call.operand(1);
+ for (SqlNode segment : path) {
+ final RexNode key = segment instanceof SqlIdentifier
+ ? rexBuilder.makeLiteral(((SqlIdentifier) segment).getSimple())
+ : cx.convertExpression(segment);
+ result = rexBuilder.makeCall(SqlStdOperatorTable.ITEM, result, key);
+ }
+ return result;
+ }
+
/**
* Converts a call to an operator into a {@link RexCall} to the same
* operator.
diff --git a/core/src/test/java/org/apache/calcite/sql/test/SqlAdvisorTest.java
b/core/src/test/java/org/apache/calcite/sql/test/SqlAdvisorTest.java
index 1e24c9ede6..94cbc6fb9e 100644
--- a/core/src/test/java/org/apache/calcite/sql/test/SqlAdvisorTest.java
+++ b/core/src/test/java/org/apache/calcite/sql/test/SqlAdvisorTest.java
@@ -309,6 +309,7 @@ class SqlAdvisorTest extends SqlValidatorTestCase {
"KEYWORD(.)",
"KEYWORD(/)",
"KEYWORD(%)",
+ "KEYWORD(:)",
"KEYWORD(<)",
"KEYWORD(<=)",
"KEYWORD(<<)",
diff --git
a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
index dc18d05520..3fb907b294 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
@@ -190,6 +190,17 @@ public static void checkActualAndReferenceFiles() {
sql(sql).ok();
}
+ @Test void testColonFieldAccess() {
+ final SqlConformance colonMode =
+ new SqlDelegatingConformance(SqlConformanceEnum.DEFAULT) {
+ @Override public boolean isColonFieldAccessAllowed() {
+ return true;
+ }
+ };
+ final String sql = "select cast(empno as variant):a.b['c'][0] from emp";
+ sql(sql).withConformance(colonMode).ok();
+ }
+
@Test void testRowValueConstructorWithSubQuery() {
final String sql = "select ROW("
+ "(select deptno\n"
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 3d7eda0edf..20602e8d75 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -1531,6 +1531,27 @@ void testLikeAndSimilarFails() {
.columnType("VARIANT");
}
+ @Test void testColonFieldAccess() {
+ final SqlConformance colonMode =
+ new SqlDelegatingConformance(SqlConformanceEnum.DEFAULT) {
+ @Override public boolean isColonFieldAccessAllowed() {
+ return true;
+ }
+ };
+
+ expr("cast(1 as variant):a.b[0].c")
+ .withConformance(colonMode)
+ .columnType("VARIANT");
+
+ sql("select ^skill^:type from dept_nested")
+ .withConformance(colonMode)
+ .fails("(?s).*Incompatible types.*");
+
+ sql("select ^bogus^:field from dept_nested")
+ .withConformance(colonMode)
+ .fails("(?s).*Column 'BOGUS' not found.*");
+ }
+
@Test void testCastRegisteredType() {
expr("cast(123 as ^customBigInt^)")
.fails("Unknown identifier 'CUSTOMBIGINT'");
@@ -10405,6 +10426,7 @@ private static int prec(SqlOperator op) {
+ "TABLE -\n"
+ "UNNEST -\n"
+ "\n"
+ + "COLON -\n"
+ "CURRENT_VALUE -\n"
+ "DEFAULT -\n"
+ "DOT -\n"
diff --git
a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
index a4dc246f8e..80d6a95f20 100644
--- a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
@@ -855,6 +855,17 @@ LogicalProject(DEPTNO=[$0], NAME=[$1], NAME0=[$2])
LogicalCorrelate(correlation=[$cor0], joinType=[inner], requiredColumns=[{0,
1}])
LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
LogicalTableFunctionScan(invocation=[DEDUP($cor0.DEPTNO, $cor0.NAME)],
rowType=[RecordType(VARCHAR(1024) NAME)])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testColonFieldAccess">
+ <Resource name="sql">
+ <![CDATA[select cast(empno as variant):a.b['c'][0] from emp]]>
+ </Resource>
+ <Resource name="plan">
+ <![CDATA[
+LogicalProject(EXPR$0=[ITEM(ITEM(ITEM(ITEM(CAST($0):VARIANT NOT NULL, 'a'),
'b'), 'c'), 0)])
+ LogicalTableScan(table=[[CATALOG, SALES, EMP]])
]]>
</Resource>
</TestCase>
diff --git a/site/_docs/reference.md b/site/_docs/reference.md
index 1c13f10324..d91b083072 100644
--- a/site/_docs/reference.md
+++ b/site/_docs/reference.md
@@ -1318,6 +1318,12 @@ ### The `VARIANT` type
as equivalent to `variant['field']`. Note, however, that the field notation
is subject to the capitalization rules of the SQL dialect, so for correct
operation the field may need to be quoted: `variant."field"`
+- path access using the colon notation: `variant:field`, with bracket
+ segments (`variant:['key']`, `variant:[0]`) and chains
+ (`variant:a.b['c'][0]`). The result is always a nullable `VARIANT`.
+ The colon operator is opt-in via the `isColonFieldAccessAllowed`
+ conformance flag; when enabled, JSON object constructors must use the
+ `KEY ... VALUE` form.
The runtime types do not need to match exactly the compile-time types.
As a compiler front-end, Calcite does not mandate exactly how the runtime types
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 fad96f70b9..955f268cc5 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
@@ -43,6 +43,7 @@
import org.apache.calcite.sql.validate.SqlAbstractConformance;
import org.apache.calcite.sql.validate.SqlConformance;
import org.apache.calcite.sql.validate.SqlConformanceEnum;
+import org.apache.calcite.sql.validate.SqlDelegatingConformance;
import org.apache.calcite.test.IntervalTest;
import org.apache.calcite.tools.Hoist;
import org.apache.calcite.util.Bug;
@@ -630,6 +631,12 @@ public class SqlParserTest {
SqlDialect.DatabaseProduct.POSTGRESQL.getDialect();
private static final SqlDialect REDSHIFT =
SqlDialect.DatabaseProduct.REDSHIFT.getDialect();
+ private static final SqlConformance COLON_FIELD =
+ new SqlDelegatingConformance(SqlConformanceEnum.DEFAULT) {
+ @Override public boolean isColonFieldAccessAllowed() {
+ return true;
+ }
+ };
/** Creates the test fixture that determines the behavior of tests.
* Sub-classes that, say, test different parser implementations should
@@ -2310,6 +2317,116 @@ void checkPeriodPredicate(Checker checker) {
.ok("(`FOO`(`A`, `B`).`C`)");
}
+ @Test void testColonFieldAccessMode() {
+ final SqlParserFixture expr =
fixture().withConformance(COLON_FIELD).expression();
+ final SqlParserFixture sql = fixture().withConformance(COLON_FIELD);
+ // Colon field access is rejected in the default conformance.
+ expr("v^:^field")
+ .fails("(?s).*Encountered \":.*\".*");
+ // Core forms: identifier, dotted identifier chain, bracketed string key,
+ // bracketed integer index.
+ expr.sql("v:field")
+ .ok("(`V`:`field`)");
+ expr.sql("v:type")
+ .ok("(`V`:`type`)");
+ expr.sql("v:key")
+ .ok("(`V`:`key`)");
+ expr.sql("v:camelCase")
+ .ok("(`V`:`camelCase`)");
+ expr.sql("v:OWNER")
+ .ok("(`V`:`OWNER`)");
+ expr.sql("v:field.nested")
+ .ok("(`V`:`field`.`nested`)");
+ expr.sql("v:['field name']")
+ .ok("(`V`:['field name'])");
+ expr.sql("v:[1]")
+ .ok("(`V`:[1])");
+ expr.sql("v:[^empno^]")
+ .fails("(?s).*Unknown identifier 'EMPNO'.*");
+ // Bracket segments chain directly after an identifier or another bracket.
+ expr.sql("v:item[1]")
+ .ok("(`V`:`item`[1])");
+ expr.sql("v:item[^key^]")
+ .fails("(?s).*Unknown identifier 'KEY'.*");
+ expr.sql("v:item[^type^]")
+ .fails("(?s).*Unknown identifier 'TYPE'.*");
+ expr.sql("v:item[1].price")
+ .ok("(`V`:`item`[1].`price`)");
+ expr.sql("v:['key'][0]")
+ .ok("(`V`:['key'][0])");
+ // Colon attaches to any expression with an outer bracket or identifier.
+ expr.sql("arr[1]:field")
+ .ok("(`ARR`[1]:`field`)");
+ expr.sql("obj['x']:nested")
+ .ok("(`OBJ`['x']:`nested`)");
+ sql.sql(
+ "select v:field, v:['field name'], arr[1]:field, obj['x']:nested from
t")
+ .ok("SELECT (`V`:`field`), (`V`:['field name']), (`ARR`[1]:`field`), "
+ + "(`OBJ`['x']:`nested`)\n"
+ + "FROM `T`");
+ }
+
+ @Test void testColonFieldAccessEdgeCases() {
+ final SqlParserFixture expr =
fixture().withConformance(COLON_FIELD).expression();
+ // Mixed identifier / bracket chains are accepted.
+ expr.sql("v:field['leaf']")
+ .ok("(`V`:`field`['leaf'])");
+ expr.sql("v:['field name'].leaf")
+ .ok("(`V`:['field name'].`leaf`)");
+ expr.sql("v:field^.^['leaf']")
+ .fails("(?s).*Encountered \"\\. \\[\".*");
+ expr.sql("v:field[2]")
+ .ok("(`V`:`field`[2])");
+ expr.sql("arr[1]:field[2]")
+ .ok("(`ARR`[1]:`field`[2])");
+ expr.sql("v.field:nested")
+ .ok("(`V`.`FIELD`:`nested`)");
+ expr.sql("obj['x']:nested['y']")
+ .ok("(`OBJ`['x']:`nested`['y'])");
+ // Colon has postfix binding: the left operand is the whole expression
atom.
+ expr.sql("a = b:field")
+ .ok("(`A` = (`B`:`field`))");
+ expr.sql("a + b:field")
+ .ok("(`A` + (`B`:`field`))");
+ expr.sql("a * arr[1]:field")
+ .ok("(`A` * (`ARR`[1]:`field`))");
+ // Colon attaches to non-identifier bases too: literals and function calls
+ // that already form a self-delimited expression atom.
+ expr.sql("null:field")
+ .ok("(NULL:`field`)");
+ expr.sql("foo(a):field")
+ .ok("(`FOO`(`A`):`field`)");
+ expr.sql("foo(v:field, arr[1]:field, obj['x']:nested['y'])")
+ .ok("`FOO`((`V`:`field`), (`ARR`[1]:`field`),
(`OBJ`['x']:`nested`['y']))");
+ // Only one colon per expression; no nested colon paths.
+ expr.sql("v:field^:^leaf")
+ .fails("(?s).*Encountered \":.*\".*");
+ expr.sql("v:['field name']^:^leaf")
+ .fails("(?s).*Encountered \":.*\".*");
+ // Expressions (function calls, arithmetic) are not allowed inside
+ // colon-path brackets; only string, identifier and integer literals.
+ expr.sql("v:[^OFFSET^(1)]")
+ .fails("(?s).*OFFSET.*");
+ expr.sql("v:[^1.5^]")
+ .fails("(?s).*Encountered \"1\\.5\".*");
+ expr.sql("v:[^1e0^]")
+ .fails("(?s).*Encountered \"1e0\".*");
+ expr.sql("v:[^i^ + 1]")
+ .fails("(?s).*Encountered \"i.*");
+ }
+
+ @Test void testColonFieldAccessRejectsMemberFunctionCalls() {
+ final SqlParserFixture expr =
fixture().withConformance(COLON_FIELD).expression();
+ expr.sql("v:field.func^(^)")
+ .fails("(?s).*Encountered \"\\(\".*");
+ expr.sql("v:[1].func^(^)")
+ .fails("(?s).*Encountered \"\\(\".*");
+ expr.sql("obj['x']:nested.func^(^)")
+ .fails("(?s).*Encountered \"\\(\".*");
+ expr.sql("v:field[1].func^(^)")
+ .fails("(?s).*Encountered \"\\(\".*");
+ }
+
@Test void testFunctionInFunction() {
expr("ln(power(2,2))")
.ok("LN(POWER(2, 2))");
@@ -6688,6 +6805,7 @@ private IntervalTest.Fixture2
getFixture2(SqlParserFixture f2,
+ "Was expecting one of:\n"
+ " <EOF> \n"
+ " \"\\(\" \\.\\.\\.\n"
+ + " \":\" \\.\\.\\.\n"
+ " \"\\.\" \\.\\.\\..*");
expr("interval '1-2' year ^to^ day")
.fails(ANY);
@@ -7834,6 +7952,8 @@ private static Consumer<List<? extends Throwable>>
checkWarnings(
sql("SELECT tbl.foo(0).col.bar(2, 3) FROM tbl")
.ok("SELECT ((`TBL`.`FOO`(0).`COL`).`BAR`(2, 3))\n"
+ "FROM `TBL`");
+ expr("rr[1].func^(^)")
+ .fails("(?s).*Encountered \"\\(\".*");
}
@Test void testUnicodeLiteral() {
@@ -9138,6 +9258,27 @@ private static Consumer<List<? extends Throwable>>
checkWarnings(
.ok("JSON_OBJECT(KEY `KEY` VALUE `VALUE` NULL ON NULL)");
}
+ @Test void testJsonObjectInColonFieldAccessMode() {
+ assumeFalse(fixture().tester.isUnparserTest());
+ final SqlParserFixture expr =
fixture().withConformance(COLON_FIELD).expression();
+ expr.sql("json_object(key v:field value arr[1]:field)")
+ .ok("JSON_OBJECT(KEY (`V`:`field`) VALUE (`ARR`[1]:`field`) NULL ON
NULL)");
+ expr.sql("json_object(key v:field.field value col)")
+ .ok("JSON_OBJECT(KEY (`V`:`field`.`field`) VALUE `COL` NULL ON NULL)");
+ expr.sql("json_object(v:field value 1)")
+ .ok("JSON_OBJECT(KEY (`V`:`field`) VALUE 1 NULL ON NULL)");
+ expr.sql("json_object(v:field^,^ 1)")
+ .fails("(?s).*Unexpected symbol ','. Was expecting 'VALUE'.*");
+ expr.sql("json_object('foo': col^,^ 1)")
+ .fails("(?s).*Unexpected symbol ','. Was expecting 'VALUE'.*");
+ expr.sql("json_object('foo'^,^ 'bar')")
+ .fails("(?s).*Unexpected symbol ','. Was expecting 'VALUE'.*");
+ // Colon mode reserves ':' for field access, so JSON colon-pair syntax
+ // must use the KEY ... VALUE form instead.
+ expr.sql("json_object('foo'^:^ 'bar')")
+ .fails("(?s).*Unexpected symbol ':'. Was expecting 'VALUE'.*");
+ }
+
@Test void testJsonType() {
expr("json_type('11.56')")
.ok("JSON_TYPE('11.56')");
@@ -9208,6 +9349,22 @@ private static Consumer<List<? extends Throwable>>
checkWarnings(
+ "FORMAT JSON NULL ON NULL)");
}
+ @Test void testJsonObjectAggInColonFieldAccessMode() {
+ assumeFalse(fixture().tester.isUnparserTest());
+ final SqlParserFixture expr =
fixture().withConformance(COLON_FIELD).expression();
+ expr.sql("json_objectagg(key v:['key'] value obj['x']:nested['y'])")
+ .ok("JSON_OBJECTAGG(KEY (`V`:['key']) VALUE "
+ + "(`OBJ`['x']:`nested`['y']) NULL ON NULL)");
+ expr.sql("json_objectagg(key v:field.field value col)")
+ .ok("JSON_OBJECTAGG(KEY (`V`:`field`.`field`) VALUE `COL` NULL ON
NULL)");
+ expr.sql("json_objectagg(v:field value col)")
+ .ok("JSON_OBJECTAGG(KEY (`V`:`field`) VALUE `COL` NULL ON NULL)");
+ expr.sql("json_objectagg(v:field^,^ col)")
+ .fails("(?s).*Unexpected symbol ','. Was expecting 'VALUE'.*");
+ expr.sql("json_objectagg('k'^,^ 1)")
+ .fails("(?s).*Unexpected symbol ','. Was expecting 'VALUE'.*");
+ }
+
/** Test case for
* <a
href="https://issues.apache.org/jira/browse/CALCITE-6003">[CALCITE-6003]
* JSON_ARRAY() with no arguments does not unparse correctly</a>. */
diff --git a/testkit/src/test/java/org/apache/calcite/test/FixtureTest.java
b/testkit/src/test/java/org/apache/calcite/test/FixtureTest.java
index 27beda9307..8aa691f4ba 100644
--- a/testkit/src/test/java/org/apache/calcite/test/FixtureTest.java
+++ b/testkit/src/test/java/org/apache/calcite/test/FixtureTest.java
@@ -55,7 +55,7 @@ public class FixtureTest {
// Postgres cast is invalid with core parser
f.sql("select 1 ^:^: integer as x")
- .fails("(?s).*Encountered \":\" at .*");
+ .fails("(?s).*Encountered \":[^\"]*\" at .*");
// Backtick fails
f.sql("select ^`^foo` from `bar``")