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 &lt;&gt; 
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``")


Reply via email to