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]