This is an automated email from the ASF dual-hosted git repository.

gianm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new ed5d8a7c2c2 feat: Shorthand x:field for JSON_VALUE(x, '$.field'). 
(#20756)
ed5d8a7c2c2 is described below

commit ed5d8a7c2c2cc7cb671e71dbd595299509fbc22b
Author: Gian Merlino <[email protected]>
AuthorDate: Thu Oct 29 11:48:49 2026 -0700

    feat: Shorthand x:field for JSON_VALUE(x, '$.field'). (#20756)
    
    This patch adds shorthand syntax for JSON_VALUE similar to the colon
    operator in Spark and Snowflake. It leverages the already-existing
    INFIX_CAST operator from https://issues.apache.org/jira/browse/CALCITE-7448.
    
    This approach also includes some Druid-specific adjustments to work
    around the original patch's limitations with JSON_OBJECT, and to ensure
    columns are named after the field being accessed (rather than EXPR$n).
---
 docs/querying/nested-columns.md                    |   6 +-
 docs/querying/sql-functions.md                     |   1 +
 docs/querying/sql-json-functions.md                |  17 ++
 sql/src/main/codegen/templates/Parser.jj           |  22 +-
 .../sql/calcite/planner/DruidConformance.java      |   7 +
 .../sql/calcite/planner/DruidSqlValidator.java     |  82 +++++-
 .../sql/calcite/CalciteNestedDataQueryTest.java    | 292 +++++++++++++++++++++
 .../sql/calcite/parser/DruidSqlParserTest.java     |  32 +++
 8 files changed, 444 insertions(+), 15 deletions(-)

diff --git a/docs/querying/nested-columns.md b/docs/querying/nested-columns.md
index 5d4ffc2ffd7..4a9afc7e3b5 100644
--- a/docs/querying/nested-columns.md
+++ b/docs/querying/nested-columns.md
@@ -505,9 +505,9 @@ The following example query illustrates how to use 
`JSON_VALUE` to extract speci
 SELECT
   product,
   department,
-  JSON_VALUE(shipTo, '$.address.country') as country,
-  JSON_VALUE(shipTo, '$.phoneNumbers[0].number') as primaryPhone,
-  JSON_VALUE(details, '$.price') as price
+  shipTo:address.country,
+  shipTo:phoneNumbers[0].number as primaryPhone,
+  details:price
 FROM nested_data_example
 ```
 
diff --git a/docs/querying/sql-functions.md b/docs/querying/sql-functions.md
index e94bb1f1116..f270554ce03 100644
--- a/docs/querying/sql-functions.md
+++ b/docs/querying/sql-functions.md
@@ -3451,6 +3451,7 @@ Extracts a primitive value from an expression at a 
specified path.
 If you include `RETURNING` and specify a SQL type (such as `VARCHAR`, 
`BIGINT`, `DOUBLE`) the function plans the query using the suggested type.
 If `RETURNING` isn't included, the function attempts to infer the type based 
on the context.
 If the function can't infer the type, it defaults to `VARCHAR`.  Primitive 
arrays can also be returned, but only if `RETURNING` is specified as an `ARRAY` 
type, e.g. `RETURNING VARCHAR ARRAY`.
+You can also write `expr:path` as shorthand for `JSON_VALUE(expr, '$.path')`, 
such as `geo_ip:city`.
 
 * **Syntax:** `JSON_VALUE(expr, path [RETURNING sqlType])`
 * **Function type:** JSON
diff --git a/docs/querying/sql-json-functions.md 
b/docs/querying/sql-json-functions.md
index 9d01cec1e44..6b669420137 100644
--- a/docs/querying/sql-json-functions.md
+++ b/docs/querying/sql-json-functions.md
@@ -73,3 +73,20 @@ Consider the following example input JSON:
   `$['y']` -> `[1, 2, 3]`
 - For a key that contains an array, to return an item in the array:<br />
   `$.y[1]` -> `2`
+
+### Colon path syntax
+
+You can write `expr:path` as shorthand for `JSON_VALUE(expr, '$.path')`. The 
path after the colon uses the same elements as JSONPath, without the leading 
`$`:
+
+|Shorthand|Equivalent|
+| --- | --- |
+|`expr:x`| `JSON_VALUE(expr, '$.x')` |
+|`expr:x.y`| `JSON_VALUE(expr, '$.x.y')` |
+|`expr:['x']`| `JSON_VALUE(expr, '$[''x'']')` |
+|`expr:x[1]`| `JSON_VALUE(expr, '$.x[1]')` |
+
+Use bracket notation or a double-quoted identifier, such as `expr:"array"`, 
for field names that are reserved keywords or contain special characters.
+
+To specify a return type, cast the result, such as `expr:x::BIGINT` or 
`CAST(expr:x AS BIGINT)`.
+
+When a select item is a colon path ending in a field name, such as `expr:x.y`, 
the output column is named after the last field (`y`), unless the select list 
contains `*` or if the name would duplicate another output column's name.
diff --git a/sql/src/main/codegen/templates/Parser.jj 
b/sql/src/main/codegen/templates/Parser.jj
index e427adb6c1a..2dfcc6824b4 100644
--- a/sql/src/main/codegen/templates/Parser.jj
+++ b/sql/src/main/codegen/templates/Parser.jj
@@ -173,6 +173,10 @@ public class ${parser.class} extends SqlAbstractParserImpl
     private int identifierMaxLength;
     private SqlConformance conformance;
     private int rowValueStarCount;
+    // BEGIN: Druid-specific code
+    // Whether to disallow a trailing colon path, since it would be ambiguous 
with the ':' in JSON_OBJECT('k': v).
+    private boolean colonPathDisallowed;
+    // END: Druid-specific code
 
     private void pushRowValueStar() {
         rowValueStarCount++;
@@ -3952,8 +3956,9 @@ void AddOptionalColonPath(List<Object> list) :
 }
 {
     [
+        // Druid: added "!colonPathDisallowed".
         LOOKAHEAD(2, <COLON> ( SimpleIdentifier() | <LBRACKET> ),
-            { this.conformance.isColonFieldAccessAllowed() })
+            { this.conformance.isColonFieldAccessAllowed() && 
!colonPathDisallowed })
         <COLON> {
             colonPos = getPos();
             segments = new ArrayList<SqlNode>();
@@ -7255,12 +7260,17 @@ List<SqlNode> JsonNameAndValue() :
     final List<SqlNode> list = new ArrayList<SqlNode>();
     final SqlNode e;
     boolean kvMode = false;
+    final boolean prevColonPathDisallowed = colonPathDisallowed;
 }
 {
     [
         LOOKAHEAD(2, <KEY> JsonName())
         <KEY> { kvMode = true; }
     ]
+    // BEGIN: Druid-specific code
+    // Disallow colon paths in the name, unless using KEY ... VALUE syntax, so 
'k': v and 'k', v keep working
+    // when isColonFieldAccessAllowed. Upstream Calcite rejects those forms 
instead.
+    { colonPathDisallowed = !kvMode; }
     e = JsonName() {
         list.add(e);
     }
@@ -7268,19 +7278,25 @@ List<SqlNode> JsonNameAndValue() :
         <VALUE>
     |
         <COMMA> {
-            if (kvMode || this.conformance.isColonFieldAccessAllowed()) {
+            if (kvMode) {
                 throw SqlUtil.newContextException(getPos(), 
RESOURCE.illegalComma());
             }
         }
     |
         <COLON> {
-            if (kvMode || this.conformance.isColonFieldAccessAllowed()) {
+            if (kvMode) {
                 throw SqlUtil.newContextException(getPos(), 
RESOURCE.illegalColon());
             }
         }
     )
+    // Allow colon paths in the value, even when this pair is inside an outer 
name, since the value is always
+    // followed by ',' or ')'.
+    { colonPathDisallowed = false; }
+    // END: Druid-specific code
     AddExpression(list, ExprContext.ACCEPT_NON_QUERY)
     {
+        // Druid: restore colonPathDisallowed.
+        colonPathDisallowed = prevColonPathDisallowed;
         return list;
     }
 }
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidConformance.java 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidConformance.java
index c0f92f2014f..14940e7ed40 100644
--- 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidConformance.java
+++ 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidConformance.java
@@ -45,6 +45,13 @@ public class DruidConformance extends SqlAbstractConformance
     return true;
   }
 
+  @Override
+  public boolean isColonFieldAccessAllowed()
+  {
+    // For x:a.b as shorthand for JSON_VALUE(x, '$.a.b').
+    return true;
+  }
+
   @Override
   public boolean isSortByOrdinal()
   {
diff --git 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
index 01548f5388b..0a6872a3863 100644
--- 
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
+++ 
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidSqlValidator.java
@@ -30,11 +30,14 @@ import org.apache.calcite.runtime.CalciteContextException;
 import org.apache.calcite.runtime.CalciteException;
 import org.apache.calcite.sql.SqlAggFunction;
 import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlCharStringLiteral;
 import org.apache.calcite.sql.SqlIdentifier;
 import org.apache.calcite.sql.SqlInsert;
 import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlLiteral;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.SqlNumericLiteral;
 import org.apache.calcite.sql.SqlOperatorTable;
 import org.apache.calcite.sql.SqlSelect;
 import org.apache.calcite.sql.SqlSelectKeyword;
@@ -69,6 +72,10 @@ import org.apache.druid.query.context.QueryContextParameters;
 import org.apache.druid.segment.column.ColumnType;
 import org.apache.druid.segment.column.Types;
 import org.apache.druid.segment.column.ValueType;
+import org.apache.druid.segment.nested.NestedPathArrayElement;
+import org.apache.druid.segment.nested.NestedPathField;
+import org.apache.druid.segment.nested.NestedPathFinder;
+import org.apache.druid.segment.nested.NestedPathPart;
 import org.apache.druid.sql.calcite.aggregation.NativelySupportsDistinct;
 import 
org.apache.druid.sql.calcite.expression.builtin.ScalarInArrayOperatorConversion;
 import org.apache.druid.sql.calcite.parser.DruidSqlIngest;
@@ -830,15 +837,18 @@ public class DruidSqlValidator extends 
BaseDruidSqlValidator
       aliasSelectItems((SqlSelect) node);
     }
 
-    final SqlNode rewritten = super.performUnconditionalRewrites(node, 
underFrom);
-
-    // Rewrite "x::TYPE" to standard CAST.
-    if (rewritten instanceof SqlCall cast
-        && SqlLibraryOperators.INFIX_CAST.equals(((SqlCall) 
rewritten).getOperator())) {
-      return SqlStdOperatorTable.CAST.createCall(cast.getParserPosition(), 
cast.getOperandList());
+    final SqlNode toRewrite;
+    if (node != null && node.getKind() == SqlKind.COLON) {
+      // Rewrite "x:path" to JSON_VALUE.
+      toRewrite = rewriteColonToJsonValue((SqlCall) node);
+    } else if (node instanceof SqlCall call && 
SqlLibraryOperators.INFIX_CAST.equals(call.getOperator())) {
+      // Rewrite "x::TYPE" to standard CAST.
+      toRewrite = 
SqlStdOperatorTable.CAST.createCall(call.getParserPosition(), 
call.getOperandList());
+    } else {
+      toRewrite = node;
     }
 
-    return rewritten;
+    return super.performUnconditionalRewrites(toRewrite, underFrom);
   }
 
   /**
@@ -928,12 +938,18 @@ public class DruidSqlValidator extends 
BaseDruidSqlValidator
   }
 
   /**
-   * Returns a preferred name for a select item. We use this so "x::TYPE" can 
be automatically named "x".
+   * Returns a preferred name for a select item. We use this so "x::TYPE" can 
be automatically named "x", and so
+   * "x:a.b" can be named "b".
    */
   @Nullable
   private static String deriveSelectItemAlias(final SqlNode item)
   {
-    if (item.getKind() == SqlKind.CAST) {
+    if (item.getKind() == SqlKind.COLON) {
+      final NestedPathPart lastPart = Util.last(getColonPathParts((SqlCall) 
item));
+      if (lastPart instanceof NestedPathField && 
!lastPart.getPartIdentifier().isEmpty()) {
+        return lastPart.getPartIdentifier();
+      }
+    } else if (item.getKind() == SqlKind.CAST) {
       final SqlCall cast = (SqlCall) item;
 
       // This rule is only for infix cast (x::TYPE), not regular CAST.
@@ -950,6 +966,54 @@ public class DruidSqlValidator extends 
BaseDruidSqlValidator
     return null;
   }
 
+  /**
+   * Rewrites "x:a.b[0]" to "JSON_VALUE(x, '$.a.b[0]')". The parser only 
produces {@link SqlKind#COLON} when
+   * {@link DruidConformance#isColonFieldAccessAllowed()}.
+   *
+   * @param call call to {@link SqlKind#COLON}, whose second operand is a 
{@link SqlNodeList} of path segments
+   */
+  private static SqlCall rewriteColonToJsonValue(final SqlCall call)
+  {
+    return SqlStdOperatorTable.JSON_VALUE.createCall(
+        call.getParserPosition(),
+        call.operand(0),
+        SqlLiteral.createCharString(
+            NestedPathFinder.toNormalizedJsonPath(getColonPathParts(call)),
+            call.operand(1).getParserPosition()
+        )
+    );
+  }
+
+  /**
+   * Returns the path of a {@link SqlKind#COLON} call, whose second operand is 
a {@link SqlNodeList} of identifiers,
+   * string literals, and integer literals.
+   */
+  private static List<NestedPathPart> getColonPathParts(final SqlCall call)
+  {
+    final List<NestedPathPart> pathParts = new ArrayList<>();
+    for (final SqlNode segment : (SqlNodeList) call.operand(1)) {
+      if (segment instanceof SqlIdentifier) {
+        pathParts.add(new NestedPathField(((SqlIdentifier) 
segment).getSimple()));
+      } else if (segment instanceof SqlNumericLiteral) {
+        pathParts.add(new NestedPathArrayElement(((SqlNumericLiteral) 
segment).intValue(true)));
+      } else {
+        // The parser accepts any string literal in brackets, including binary 
strings like X'61'.
+        final SqlLiteral literal = SqlLiteral.unchain(segment);
+        if (!(literal instanceof SqlCharStringLiteral)) {
+          throw buildCalciteContextException(
+              StringUtils.format(
+                  "Invalid path element [%s] after ':'. Use a character string 
like ['x'] or an integer like [0].",
+                  segment
+              ),
+              segment
+          );
+        }
+        pathParts.add(new NestedPathField(literal.getValueAs(String.class)));
+      }
+    }
+    return pathParts;
+  }
+
   private SqlNode getSqlNodeFor(SqlInsert insert, int idx)
   {
     SqlNode src = insert.getSource();
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteNestedDataQueryTest.java
 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteNestedDataQueryTest.java
index ea8f4990c66..9c6414501aa 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteNestedDataQueryTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteNestedDataQueryTest.java
@@ -2693,6 +2693,45 @@ public abstract class CalciteNestedDataQueryTest extends 
BaseCalciteQueryTest
     );
   }
 
+  @Test
+  public void testGroupByColonPathSelectorFilter()
+  {
+    testQuery(
+        "SELECT "
+        + "nest:x, "
+        + "SUM(cnt) "
+        + "FROM druid.nested WHERE nest:['x'] = '100' GROUP BY 1",
+        ImmutableList.of(
+            GroupByQuery.builder()
+                        .setDataSource(DATA_SOURCE)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(
+                            new NestedFieldVirtualColumn("nest", "$.x", "v0", 
ColumnType.STRING)
+                        )
+                        .setDimensions(
+                            dimensions(
+                                new DefaultDimensionSpec("v0", "d0")
+                            )
+                        )
+                        .setDimFilter(equality("v0", "100", ColumnType.STRING))
+                        .setAggregatorSpecs(aggregators(new 
LongSumAggregatorFactory("a0", "cnt")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        ),
+        ImmutableList.of(
+            new Object[]{
+                "100",
+                2L
+            }
+        ),
+        RowSignature.builder()
+                    .add("x", ColumnType.STRING)
+                    .add("EXPR$1", ColumnType.LONG)
+                    .build()
+    );
+  }
+
   @Test
   public void testGroupByPathSelectorFilterCoalesce()
   {
@@ -4903,6 +4942,198 @@ public abstract class CalciteNestedDataQueryTest 
extends BaseCalciteQueryTest
     );
   }
 
+  @Test
+  public void testGroupByColonPathFromSubquery()
+  {
+    testQuery(
+        "SELECT x, SUM(cnt) "
+        + "FROM (SELECT nester:n.x, cnt FROM druid.nested) GROUP BY x",
+        ImmutableList.of(
+            GroupByQuery.builder()
+                        .setDataSource(DATA_SOURCE)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(
+                            new NestedFieldVirtualColumn("nester", "$.n.x", 
"v0", ColumnType.STRING)
+                        )
+                        .setDimensions(
+                            dimensions(
+                                new DefaultDimensionSpec("v0", "d0")
+                            )
+                        )
+                        .setAggregatorSpecs(aggregators(new 
LongSumAggregatorFactory("a0", "cnt")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"1", 1L},
+            new Object[]{"hello", 1L}
+        ),
+        RowSignature.builder()
+                    .add("x", ColumnType.STRING)
+                    .add("EXPR$1", ColumnType.LONG)
+                    .build()
+    );
+  }
+
+  @Test
+  public void testGroupByColonNestedArrayPath()
+  {
+    testQuery(
+        "SELECT "
+        + "nester:['array'][1], "
+        + "SUM(cnt) "
+        + "FROM druid.nested GROUP BY 1",
+        ImmutableList.of(
+            GroupByQuery.builder()
+                        .setDataSource(DATA_SOURCE)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(
+                            new NestedFieldVirtualColumn("nester", 
"$.array[1]", "v0", ColumnType.STRING)
+                        )
+                        .setDimensions(
+                            dimensions(
+                                new DefaultDimensionSpec("v0", "d0", 
ColumnType.STRING)
+                            )
+                        )
+                        .setAggregatorSpecs(aggregators(new 
LongSumAggregatorFactory("a0", "cnt")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        ),
+        ImmutableList.of(
+            new Object[]{null, 5L},
+            new Object[]{"b", 2L}
+        ),
+        RowSignature.builder()
+                    .add("EXPR$0", ColumnType.STRING)
+                    .add("EXPR$1", ColumnType.LONG)
+                    .build()
+    );
+  }
+
+  @Test
+  public void testGroupByColonPathCastBigint()
+  {
+    testQuery(
+        "SELECT "
+        + "CAST(nest:x AS BIGINT), "
+        + "SUM(cnt) "
+        + "FROM druid.nested WHERE CAST(nest:x AS BIGINT) > 100 GROUP BY 1",
+        ImmutableList.of(
+            GroupByQuery.builder()
+                        .setDataSource(DATA_SOURCE)
+                        .setInterval(querySegmentSpec(Filtration.eternity()))
+                        .setGranularity(Granularities.ALL)
+                        .setVirtualColumns(
+                            new NestedFieldVirtualColumn("nest", "$.x", "v0", 
ColumnType.LONG)
+                        )
+                        .setDimensions(
+                            dimensions(
+                                new DefaultDimensionSpec("v0", "d0", 
ColumnType.LONG)
+                            )
+                        )
+                        .setDimFilter(range("v0", ColumnType.LONG, 100L, null, 
true, false))
+                        .setAggregatorSpecs(aggregators(new 
LongSumAggregatorFactory("a0", "cnt")))
+                        .setContext(QUERY_CONTEXT_DEFAULT)
+                        .build()
+        ),
+        ImmutableList.of(
+            new Object[]{200L, 1L}
+        ),
+        RowSignature.builder()
+                    .add("EXPR$0", ColumnType.LONG)
+                    .add("EXPR$1", ColumnType.LONG)
+                    .build()
+    );
+  }
+
+  @Test
+  public void testColonPathCastVarcharArray()
+  {
+    testBuilder()
+        .sql(
+            "SELECT CAST(nester:\"array\" AS VARCHAR ARRAY), 
CAST(nester:['array'][1] AS VARCHAR) "
+            + "FROM druid.nested"
+        )
+        .queryContext(QUERY_CONTEXT_NO_STRINGIFY_ARRAY)
+        .expectedQueries(
+            ImmutableList.of(
+                Druids.newScanQueryBuilder()
+                      .dataSource(DATA_SOURCE)
+                      .intervals(querySegmentSpec(Filtration.eternity()))
+                      .virtualColumns(
+                          new NestedFieldVirtualColumn("nester", "$.array", 
"v0", ColumnType.STRING_ARRAY),
+                          new NestedFieldVirtualColumn("nester", "$.array[1]", 
"v1", ColumnType.STRING)
+                      )
+                      .columns("v0", "v1")
+                      .columnTypes(ColumnType.STRING_ARRAY, ColumnType.STRING)
+                      
.resultFormat(ScanQuery.ResultFormat.RESULT_FORMAT_COMPACTED_LIST)
+                      .build()
+            )
+        )
+        .expectedResults(
+            ImmutableList.of(
+                new Object[]{Arrays.asList("a", "b"), "b"},
+                new Object[]{null, null},
+                new Object[]{null, null},
+                new Object[]{null, null},
+                new Object[]{null, null},
+                new Object[]{Arrays.asList("a", "b"), "b"},
+                new Object[]{null, null}
+            )
+        )
+        .expectedSignature(
+            RowSignature.builder()
+                        .add("EXPR$0", ColumnType.STRING_ARRAY)
+                        .add("EXPR$1", ColumnType.STRING)
+                        .build()
+        )
+        .run();
+  }
+
+  @Test
+  public void testColonPathInfixCast()
+  {
+    testBuilder()
+        .sql("SELECT nester:\"array\"::VARCHAR ARRAY, nest:x::BIGINT FROM 
druid.nested")
+        .queryContext(QUERY_CONTEXT_NO_STRINGIFY_ARRAY)
+        .expectedQueries(
+            ImmutableList.of(
+                Druids.newScanQueryBuilder()
+                      .dataSource(DATA_SOURCE)
+                      .intervals(querySegmentSpec(Filtration.eternity()))
+                      .virtualColumns(
+                          new NestedFieldVirtualColumn("nester", "$.array", 
"v0", ColumnType.STRING_ARRAY),
+                          new NestedFieldVirtualColumn("nest", "$.x", "v1", 
ColumnType.LONG)
+                      )
+                      .columns("v0", "v1")
+                      .columnTypes(ColumnType.STRING_ARRAY, ColumnType.LONG)
+                      
.resultFormat(ScanQuery.ResultFormat.RESULT_FORMAT_COMPACTED_LIST)
+                      .build()
+            )
+        )
+        .expectedResults(
+            ImmutableList.of(
+                new Object[]{Arrays.asList("a", "b"), 100L},
+                new Object[]{null, null},
+                new Object[]{null, 200L},
+                new Object[]{null, null},
+                new Object[]{null, null},
+                new Object[]{Arrays.asList("a", "b"), 100L},
+                new Object[]{null, null}
+            )
+        )
+        .expectedSignature(
+            RowSignature.builder()
+                        .add("array", ColumnType.STRING_ARRAY)
+                        .add("x", ColumnType.LONG)
+                        .build()
+        )
+        .run();
+  }
+
   @Test
   public void testGroupByInvalidPath()
   {
@@ -4917,6 +5148,17 @@ public abstract class CalciteNestedDataQueryTest extends 
BaseCalciteQueryTest
     );
   }
 
+  @Test
+  public void testColonPathBinaryStringElement()
+  {
+    testQueryThrows(
+        "SELECT nest:[X'61'] FROM druid.nested",
+        DruidExceptionMatcher
+            .invalidInput()
+            .expectMessageContains("Invalid path element [X'61'] after ':'")
+    );
+  }
+
   @Test
   public void testJsonQuery()
   {
@@ -5019,6 +5261,56 @@ public abstract class CalciteNestedDataQueryTest extends 
BaseCalciteQueryTest
     );
   }
 
+  @Test
+  public void testJsonObjectWithColonPath()
+  {
+    testQuery(
+        "SELECT JSON_OBJECT('n': JSON_QUERY(nester, '$.n'), 'x': nest:x)\n"
+        + "FROM druid.nested",
+        ImmutableList.of(
+            Druids.newScanQueryBuilder()
+                  .dataSource(DATA_SOURCE)
+                  .intervals(querySegmentSpec(Filtration.eternity()))
+                  .virtualColumns(
+                      new NestedObjectVirtualColumn(
+                          "v0",
+                          ImmutableMap.of(
+                              "n", new 
NestedObjectVirtualColumn.TypedExpression("\"v1\"", ColumnType.NESTED_DATA),
+                              "x", new 
NestedObjectVirtualColumn.TypedExpression("\"v2\"", ColumnType.STRING)
+                          ),
+                          queryFramework().macroTable()
+                      ),
+                      new NestedFieldVirtualColumn(
+                          "nester",
+                          "v1",
+                          ColumnType.NESTED_DATA,
+                          null,
+                          true,
+                          "$.n",
+                          false
+                      ),
+                      new NestedFieldVirtualColumn("nest", "$.x", "v2", 
ColumnType.STRING)
+                  )
+                  .columns("v0")
+                  .columnTypes(ColumnType.NESTED_DATA)
+                  
.resultFormat(ScanQuery.ResultFormat.RESULT_FORMAT_COMPACTED_LIST)
+                  .build()
+        ),
+        ImmutableList.of(
+            new Object[]{"{\"x\":\"100\",\"n\":{\"x\":\"hello\"}}"},
+            new Object[]{"{\"x\":null,\"n\":null}"},
+            new Object[]{"{\"x\":\"200\",\"n\":null}"},
+            new Object[]{"{\"x\":null,\"n\":null}"},
+            new Object[]{"{\"x\":null,\"n\":null}"},
+            new Object[]{"{\"x\":\"100\",\"n\":{\"x\":1}}"},
+            new Object[]{"{\"x\":null,\"n\":null}"}
+        ),
+        RowSignature.builder()
+                    .add("EXPR$0", ColumnType.NESTED_DATA)
+                    .build()
+    );
+  }
+
   @Test
   public void testJsonMerging_lhsStringLiteral()
   {
diff --git 
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java 
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
index e9019e6f822..22b6f84a0f4 100644
--- 
a/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
+++ 
b/sql/src/test/java/org/apache/druid/sql/calcite/parser/DruidSqlParserTest.java
@@ -20,6 +20,7 @@
 package org.apache.druid.sql.calcite.parser;
 
 import org.apache.calcite.sql.SqlLiteral;
+import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.parser.SqlParserPos;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.DateString;
@@ -190,4 +191,35 @@ public class DruidSqlParserTest
     Assertions.assertTrue(exception.getMessage().contains("Received an 
unexpected token"));
     Assertions.assertFalse(exception.getMessage().contains("is a reserved 
keyword"));
   }
+
+  @Test
+  public void testParse_jsonObjectWithColonPaths()
+  {
+    final SqlNode node = DruidSqlParser.parse(
+        "SELECT JSON_OBJECT('a': x:b, 'c', y:d.e, KEY z:f VALUE 1, KEY 'g' 
VALUE w:['h'][0])",
+        false
+    ).getMainStatement();
+
+    Assertions.assertEquals(
+        "SELECT JSON_OBJECT(KEY 'a' VALUE `x`:`b`, KEY 'c' VALUE `y`:`d`.`e`, "
+        + "KEY `z`:`f` VALUE 1, KEY 'g' VALUE `w`:['h'][0] NULL ON NULL)",
+        node.toString()
+    );
+  }
+
+  @Test
+  public void testParse_jsonObjectWithColonPathInValueInsideName()
+  {
+    // The inner value x:c allows a colon path, but the outer name still ends 
before ": z".
+    final SqlNode node = DruidSqlParser.parse(
+        "SELECT JSON_OBJECT(JSON_VALUE(JSON_OBJECT('b': x:c), '$.b') || y : 
z)",
+        false
+    ).getMainStatement();
+
+    Assertions.assertEquals(
+        "SELECT JSON_OBJECT(KEY JSON_VALUE(JSON_OBJECT(KEY 'b' VALUE `x`:`c` 
NULL ON NULL), '$.b') || `y` "
+        + "VALUE `z` NULL ON NULL)",
+        node.toString()
+    );
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to