This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new e40d93100bc Add fast jsonExtractScalar transform variants (#19012)
e40d93100bc is described below
commit e40d93100bce828d11ba6e167dea8c04b6559ac3
Author: Xiang Fu <[email protected]>
AuthorDate: Sun Jul 26 13:28:40 2026 -0700
Add fast jsonExtractScalar transform variants (#19012)
* Add fast jsonExtractScalar transform functions
* Fix jsonExtractScalar operand checker to accept constant-foldable jsonPath
The shared operand type checker required jsonPath and resultsType to be
literal SqlNodes. Operand checking runs before PinotEvaluateLiteralRule
folds constants, so this rejected queries such as
jsonExtractScalar(col, CONCAT('$.', 'foo'), 'INT') that fold to a literal
and plan/execute correctly on master.
Relax the jsonPath operand to accept any CHARACTER expression. Keep the
literal requirement on resultsType (read during validation for return-type
inference; a foldable value there would silently infer VARCHAR) and on
defaultValue. Genuinely non-literal operands are still rejected on the leaf
stage by ParserUtils#validateFunction.
Restores the QueryRunnerTest case the original checker had forced to change,
and adds planner + runtime regression coverage for foldable and non-literal
jsonPath across all three variants.
---
.../common/function/FastJsonPathExtractor.java | 11 +
.../common/function/TransformFunctionType.java | 56 ++++-
.../org/apache/pinot/sql/parsers/ParserUtils.java | 19 +-
.../common/function/FastJsonPathExtractorTest.java | 3 +
.../JsonExtractScalarTransformFunction.java | 173 ++++++++++++--
.../function/TransformFunctionFactory.java | 4 +
.../pinot/core/function/FunctionRegistryTest.java | 3 +-
.../JsonExtractScalarTransformFunctionTest.java | 260 +++++++++++++--------
.../integration/tests/custom/JsonPathTest.java | 39 +++-
.../resources/udf-test-results/all-functions.yaml | 32 +++
.../apache/pinot/query/QueryCompilationTest.java | 68 ++++++
.../pinot/query/QueryEnvironmentTestBase.java | 7 +
.../query/runtime/queries/QueryRunnerTest.java | 17 ++
13 files changed, 559 insertions(+), 133 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
b/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
index 2979d8b4bcc..c2dd133e462 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
@@ -164,6 +164,17 @@ public final class FastJsonPathExtractor {
}
}
+ /// UTF-8 overload of [#extract(String, SimpleJsonPath[], Object[], boolean,
boolean)] with the same output and
+ /// exception contract.
+ public static void extract(byte[] json, SimpleJsonPath[] paths, Object[]
out, boolean useBigDecimal,
+ boolean earlyExit) {
+ try (JsonParser parser = FACTORY.createParser(json)) {
+ extract(parser, paths, out, useBigDecimal, earlyExit);
+ } catch (IOException e) {
+ throw new InvalidJsonException(e);
+ }
+ }
+
private static void extract(JsonParser parser, SimpleJsonPath[] paths,
Object[] out, boolean useBigDecimal,
boolean earlyExit)
throws IOException {
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
b/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
index e06918a49e2..f4584173ebc 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
@@ -30,6 +30,7 @@ import org.apache.calcite.sql.type.ReturnTypes;
import org.apache.calcite.sql.type.SqlOperandCountRanges;
import org.apache.calcite.sql.type.SqlOperandTypeChecker;
import org.apache.calcite.sql.type.SqlReturnTypeInference;
+import org.apache.calcite.sql.type.SqlSingleOperandTypeChecker;
import org.apache.calcite.sql.type.SqlTypeFamily;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.sql.type.SqlTypeTransforms;
@@ -103,10 +104,11 @@ public enum TransformFunctionType {
// JSON extract functions
JSON_EXTRACT_SCALAR("jsonExtractScalar",
- opBinding -> positionalReturnTypeInferenceFromStringLiteral(opBinding,
2, SqlTypeName.VARCHAR),
- OperandTypes.family(
- List.of(SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER,
SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER),
- i -> i == 3)),
+ TransformFunctionType::jsonExtractScalarReturnTypeInference,
jsonExtractScalarOperandTypeChecker()),
+ JSON_EXTRACT_SCALAR_FAST("jsonExtractScalarFast",
+ TransformFunctionType::jsonExtractScalarReturnTypeInference,
jsonExtractScalarOperandTypeChecker()),
+ JSON_EXTRACT_SCALAR_FIRST_MATCH("jsonExtractScalarFirstMatch",
+ TransformFunctionType::jsonExtractScalarReturnTypeInference,
jsonExtractScalarOperandTypeChecker()),
JSON_EXTRACT_INDEX("jsonExtractIndex",
opBinding -> positionalReturnTypeInferenceFromStringLiteral(opBinding,
2, SqlTypeName.VARCHAR),
OperandTypes.family(
@@ -317,6 +319,50 @@ public enum TransformFunctionType {
return opBinding.getTypeFactory().createSqlType(defaultSqlType);
}
+ private static RelDataType
jsonExtractScalarReturnTypeInference(SqlOperatorBinding opBinding) {
+ if (opBinding.getOperandCount() > 2 && opBinding.isOperandLiteral(2,
false)) {
+ String resultsType = opBinding.getOperandLiteralValue(2,
String.class).toUpperCase();
+ RelDataTypeFactory typeFactory = opBinding.getTypeFactory();
+ switch (resultsType) {
+ case "JSON":
+ return typeFactory.createSqlType(SqlTypeName.VARCHAR);
+ case "BOOLEAN_ARRAY":
+ return
typeFactory.createArrayType(typeFactory.createSqlType(SqlTypeName.BOOLEAN), -1);
+ case "TIMESTAMP_ARRAY":
+ return
typeFactory.createArrayType(typeFactory.createSqlType(SqlTypeName.TIMESTAMP),
-1);
+ default:
+ break;
+ }
+ }
+ return positionalReturnTypeInferenceFromStringLiteral(opBinding, 2,
SqlTypeName.VARCHAR);
+ }
+
+ /// Operand checker shared by `jsonExtractScalar` and its `Fast` /
`FirstMatch` variants.
+ ///
+ /// `jsonPath` deliberately does **not** require [OperandTypes#LITERAL].
Operand checking runs on the raw
+ /// `SqlNode` tree, before `PinotEvaluateLiteralRule` folds constant
expressions, so demanding a literal here
+ /// would reject `jsonExtractScalar(col, CONCAT('$.', 'foo'), 'INT')` and
other constant-foldable paths that fold
+ /// to a literal and run correctly. A genuinely non-literal `jsonPath` is
rejected later, when the leaf stage
+ /// converts the folded call back into a Pinot expression.
+ ///
+ /// `resultsType` **does** require a literal, because
[#jsonExtractScalarReturnTypeInference] reads it during
+ /// validation — also before folding — to derive the return type. Accepting
a foldable `resultsType` there would
+ /// silently infer `VARCHAR` while the leaf stage extracts the real type,
producing a plan whose schema disagrees
+ /// with its data.
+ private static SqlOperandTypeChecker jsonExtractScalarOperandTypeChecker() {
+ SqlSingleOperandTypeChecker jsonInputTypeChecker =
OperandTypes.or(OperandTypes.CHARACTER, OperandTypes.BINARY);
+ SqlSingleOperandTypeChecker resultsTypeChecker =
OperandTypes.and(OperandTypes.CHARACTER, OperandTypes.LITERAL);
+ return OperandTypes.or(
+ OperandTypes.sequence(
+ (operator, ignored) -> "'" + operator.getName()
+ + "(<CHARACTER_OR_BINARY>, <CHARACTER>, <CHARACTER_LITERAL>)'",
+ jsonInputTypeChecker, OperandTypes.CHARACTER, resultsTypeChecker),
+ OperandTypes.sequence(
+ (operator, ignored) -> "'" + operator.getName()
+ + "(<CHARACTER_OR_BINARY>, <CHARACTER>, <CHARACTER_LITERAL>,
<LITERAL>)'",
+ jsonInputTypeChecker, OperandTypes.CHARACTER, resultsTypeChecker,
OperandTypes.NULLABLE_LITERAL));
+ }
+
private static RelDataType componentType(SqlOperatorBinding opBinding) {
return opBinding.getOperandType(0).getComponentType();
}
@@ -362,6 +408,8 @@ public enum TransformFunctionType {
return typeFactory.createSqlType(SqlTypeName.VARBINARY);
case "BIG_DECIMAL":
return typeFactory.createSqlType(SqlTypeName.DECIMAL);
+ case "BIG_DECIMAL_ARRAY":
+ return
typeFactory.createArrayType(typeFactory.createSqlType(SqlTypeName.DECIMAL), -1);
default:
SqlTypeName sqlTypeName = SqlTypeName.get(operandTypeStr);
if (sqlTypeName == null) {
diff --git
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/ParserUtils.java
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/ParserUtils.java
index 23b8a70237d..4406a44707d 100644
--- a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/ParserUtils.java
+++ b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/ParserUtils.java
@@ -29,7 +29,13 @@ public class ParserUtils {
public static void validateFunction(String canonicalName, List<Expression>
operands) {
switch (canonicalName) {
case "jsonextractscalar":
- validateJsonExtractScalarFunction(operands);
+ validateJsonExtractScalarFunction("jsonExtractScalar", operands);
+ break;
+ case "jsonextractscalarfast":
+ validateJsonExtractScalarFunction("jsonExtractScalarFast", operands);
+ break;
+ case "jsonextractscalarfirstmatch":
+ validateJsonExtractScalarFunction("jsonExtractScalarFirstMatch",
operands);
break;
case "jsonextractkey":
validateJsonExtractKeyFunction(operands);
@@ -133,19 +139,20 @@ public class ParserUtils {
return result.toString();
}
- private static void validateJsonExtractScalarFunction(List<Expression>
operands) {
+ private static void validateJsonExtractScalarFunction(String functionName,
List<Expression> operands) {
// Check that there are 3 or 4 arguments
int numOperands = operands.size();
if (numOperands != 3 && numOperands != 4) {
throw new SqlCompilationException(
- "Expect 3 or 4 arguments for transform function:
jsonExtractScalar(jsonFieldName, 'jsonPath', "
- + "'resultsType', ['defaultValue'])");
+ "Expect 3 or 4 arguments for transform function: " + functionName
+ + "(jsonFieldName, 'jsonPath', 'resultsType',
['defaultValue'])");
}
if (!operands.get(1).isSetLiteral() || !operands.get(2).isSetLiteral() ||
(numOperands == 4 && !operands.get(3)
.isSetLiteral())) {
throw new SqlCompilationException(
- "Expect the 2nd/3rd/4th argument of transform function:
jsonExtractScalar(jsonFieldName, 'jsonPath', "
- + "'resultsType', ['defaultValue']) to be a single-quoted
literal value.");
+ "Expect the 2nd and 3rd arguments of transform function: " +
functionName
+ + "(jsonFieldName, 'jsonPath', 'resultsType', ['defaultValue'])
to be single-quoted literal values, "
+ + "and the optional 4th argument to be a literal value.");
}
}
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
b/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
index a29ac274f84..41c398d961d 100644
---
a/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
+++
b/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
@@ -620,7 +620,10 @@ public class FastJsonPathExtractorTest {
}
Object[] batch = new Object[paths.length];
FastJsonPathExtractor.extract(json, compiled, batch, false, false);
+ Object[] bytesBatch = new Object[paths.length];
+ FastJsonPathExtractor.extract(json.getBytes(StandardCharsets.UTF_8),
compiled, bytesBatch, false, false);
for (int i = 0; i < paths.length; i++) {
+ assertEquals(bytesBatch[i], batch[i], paths[i]);
assertEquals(batch[i], FastJsonPathExtractor.extract(json, compiled[i],
false, false), paths[i]);
assertEquals(batch[i], PLAIN_CONTEXT.parse(json).read(paths[i],
NO_PREDICATES), paths[i]);
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java
index dee336e2dc9..21e0f3e0cde 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java
@@ -32,7 +32,9 @@ import java.util.List;
import java.util.Map;
import java.util.function.IntFunction;
import javax.annotation.Nullable;
+import org.apache.pinot.common.function.FastJsonPathExtractor;
import org.apache.pinot.common.function.JsonPathCache;
+import org.apache.pinot.common.function.SimpleJsonPath;
import org.apache.pinot.core.operator.ColumnContext;
import org.apache.pinot.core.operator.blocks.ValueBlock;
import org.apache.pinot.core.operator.transform.TransformResultMetadata;
@@ -41,15 +43,36 @@ import org.apache.pinot.core.util.NumericException;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.utils.BooleanUtils;
import org.apache.pinot.spi.utils.JsonUtils;
+import org.apache.pinot.spi.utils.PinotDataType;
import org.apache.pinot.spi.utils.TimestampUtils;
import org.roaringbitmap.RoaringBitmap;
-/// Implements the `jsonExtractScalar(jsonField, jsonPath, resultsType[,
defaultValue])` transform.
-/// Reads a JSON document from `jsonField` for each row, resolves the
+/// Implements the typed JSON extraction transforms:
+///
+/// | Function | Simple-path behavior |
+/// | --- | --- |
+/// | `jsonExtractScalar` | Builds the existing Jayway DOM; unchanged for
backward compatibility. |
+/// | `jsonExtractScalarFast` | Uses [FastJsonPathExtractor] and scans the
full root value, preserving Jayway results. |
+/// | `jsonExtractScalarFirstMatch` | Uses [FastJsonPathExtractor] and stops
when the addressed value is found. |
+///
+/// Each function reads a JSON document from `jsonField` for each row,
resolves the
/// [Stefan Goessner JsonPath](https://goessner.net/articles/JsonPath/)
expression against it, and
/// converts the resolved value to `resultsType`.
///
+/// ```sql
+/// SELECT jsonExtractScalarFast(payload, '$.user.id', 'LONG', '-1'),
+/// jsonExtractScalarFirstMatch(payload, '$.service.name', 'STRING',
'unknown')
+/// FROM events
+/// ```
+///
+/// The fast variants optimize simple linear paths (`$` followed by `.key`,
`['key']`, or `[index]`) and fall back
+/// to the existing Jayway implementation for complex paths, unsupported input
roots, and any fast-extractor
+/// exception. `FirstMatch` is faster for fields near the start of a document,
but deliberately resolves duplicate
+/// keys to the first non-null occurrence and does not validate malformed
content after the resolved value. Use it
+/// only for well-formed, duplicate-free JSON. `Fast` scans the full root
value and retains Jayway's last-key-wins
+/// and malformed-document behavior; see [FastJsonPathExtractor] for one
documented unaddressed-value edge case.
+///
/// **Arguments:**
/// - `jsonField` — single-value `STRING` or `BYTES` column / transform
expression containing JSON.
/// - `jsonPath` — JsonPath expression used to read the value.
@@ -60,7 +83,10 @@ import org.roaringbitmap.RoaringBitmap;
///
/// **Supported `resultsType`:** `INT`, `LONG`, `FLOAT`, `DOUBLE`,
`BIG_DECIMAL`, `BOOLEAN`, `TIMESTAMP`,
/// `STRING`, `JSON`, `BYTES`, plus `_ARRAY` variants of `INT` / `LONG` /
`FLOAT` / `DOUBLE` /
-/// `BIG_DECIMAL` / `STRING`.
+/// `BIG_DECIMAL` / `BOOLEAN` / `TIMESTAMP` / `STRING`.
+///
+/// Multi-stage planning accepts all of these result types, but currently
lowers `BIG_DECIMAL_ARRAY` to
+/// `DOUBLE_ARRAY`. Use the single-stage engine when decimal-array precision
must be preserved.
///
/// **Per-row coercion** of the JsonPath result to `resultsType`:
/// - `BOOLEAN` (stored as `INT`) follows Pinot's numeric convention — any
non-zero `Number` is true;
@@ -69,6 +95,7 @@ import org.roaringbitmap.RoaringBitmap;
/// [TimestampUtils#toMillisSinceEpoch] (ISO-8601 and numeric millis
strings).
/// - `STRING` returns `String` values as-is; other JSON values are serialized
via
/// [JsonUtils#objectToString].
+/// - `BYTES` decodes a Base64-encoded JSON string, matching
[PinotDataType#JSON].
/// - `BIG_DECIMAL` and `STRING` paths use a BigDecimal-preserving JSON parser
/// (`JSON_PARSER_CONTEXT_WITH_BIG_DECIMAL`) to avoid precision loss on
numeric values; other paths use
/// the default parser.
@@ -76,6 +103,14 @@ import org.roaringbitmap.RoaringBitmap;
/// `parse*(toString())`.
public class JsonExtractScalarTransformFunction extends BaseTransformFunction {
public static final String FUNCTION_NAME = "jsonExtractScalar";
+ public static final String FAST_FUNCTION_NAME = "jsonExtractScalarFast";
+ public static final String FIRST_MATCH_FUNCTION_NAME =
"jsonExtractScalarFirstMatch";
+
+ private enum ExtractionMode {
+ JAYWAY,
+ FAST,
+ FIRST_MATCH
+ }
// This ObjectMapper requires special configurations, hence we can't use
pinot JsonUtils here.
private static final ObjectMapper OBJECT_MAPPER_WITH_BIG_DECIMAL =
@@ -89,17 +124,44 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
new Configuration.ConfigurationBuilder().jsonProvider(new
JacksonJsonProvider())
.mappingProvider(new
JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS).build());
+ private final String _functionName;
+ private final ExtractionMode _extractionMode;
private TransformFunction _jsonFieldTransformFunction;
private JsonPath _jsonPath;
+ @Nullable
+ private SimpleJsonPath _simpleJsonPath;
private DataType _dataType;
private DataType _storedType;
private Object _defaultValue;
private boolean _defaultIsNull;
private TransformResultMetadata _resultMetadata;
+ public JsonExtractScalarTransformFunction() {
+ this(FUNCTION_NAME, ExtractionMode.JAYWAY);
+ }
+
+ private JsonExtractScalarTransformFunction(String functionName,
ExtractionMode extractionMode) {
+ _functionName = functionName;
+ _extractionMode = extractionMode;
+ }
+
+ /// Full-scan fast variant of [JsonExtractScalarTransformFunction].
+ public static final class Fast extends JsonExtractScalarTransformFunction {
+ public Fast() {
+ super(FAST_FUNCTION_NAME, ExtractionMode.FAST);
+ }
+ }
+
+ /// First-match fast variant of [JsonExtractScalarTransformFunction].
+ public static final class FirstMatch extends
JsonExtractScalarTransformFunction {
+ public FirstMatch() {
+ super(FIRST_MATCH_FUNCTION_NAME, ExtractionMode.FIRST_MATCH);
+ }
+ }
+
@Override
public String getName() {
- return FUNCTION_NAME;
+ return _functionName;
}
@Override
@@ -109,28 +171,30 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
// Check that there are exactly 3 or 4 arguments
if (arguments.size() < 3 || arguments.size() > 4) {
throw new IllegalArgumentException(
- "Expected 3/4 arguments for transform function:
jsonExtractScalar(jsonFieldName, 'jsonPath', 'resultsType',"
- + " ['defaultValue'])");
+ "Expected 3/4 arguments for transform function: " + _functionName
+ + "(jsonFieldName, 'jsonPath', 'resultsType',
['defaultValue'])");
}
TransformFunction firstArgument = arguments.get(0);
if (firstArgument instanceof LiteralTransformFunction ||
!firstArgument.getResultMetadata().isSingleValue()) {
throw new IllegalArgumentException(
- "The first argument of jsonExtractScalar transform function must be
a single-valued column or a transform "
- + "function");
+ "The first argument of " + _functionName
+ + " transform function must be a single-valued column or a
transform function");
}
_jsonFieldTransformFunction = firstArgument;
String jsonPathString = ((LiteralTransformFunction)
arguments.get(1)).getStringLiteral();
_jsonPath = JsonPathCache.INSTANCE.getOrCompute(jsonPathString);
+ _simpleJsonPath = _extractionMode == ExtractionMode.JAYWAY ? null :
SimpleJsonPath.compile(jsonPathString);
String resultsType = ((LiteralTransformFunction)
arguments.get(2)).getStringLiteral().toUpperCase();
boolean isSingleValue = !resultsType.endsWith("_ARRAY");
try {
_dataType = DataType.valueOf(isSingleValue ? resultsType :
resultsType.substring(0, resultsType.length() - 6));
} catch (Exception e) {
throw new IllegalArgumentException(String.format(
- "Unsupported results type: %s for jsonExtractScalar function.
Supported types are: "
+ "Unsupported results type: %s for %s function. Supported types are: "
+
"INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES/"
- +
"INT_ARRAY/LONG_ARRAY/FLOAT_ARRAY/DOUBLE_ARRAY/BIG_DECIMAL_ARRAY/STRING_ARRAY",
resultsType));
+ +
"INT_ARRAY/LONG_ARRAY/FLOAT_ARRAY/DOUBLE_ARRAY/BIG_DECIMAL_ARRAY/BOOLEAN_ARRAY/TIMESTAMP_ARRAY/"
+ + "STRING_ARRAY", resultsType, _functionName));
}
_storedType = _dataType.getStoredType();
if (arguments.size() == 4) {
@@ -170,7 +234,8 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
break;
default:
throw new IllegalArgumentException(
- "Unsupported results type: " + _dataType + " for
jsonExtractScalar function. Supported types are: "
+ "Unsupported results type: " + _dataType + " for " +
_functionName
+ + " function. Supported types are: "
+
"INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES"
);
}
@@ -388,6 +453,34 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
return _stringValuesSV;
}
+ @Override
+ public byte[][] transformToBytesValuesSV(ValueBlock valueBlock) {
+ if (_storedType != DataType.BYTES) {
+ return super.transformToBytesValuesSV(valueBlock);
+ }
+ initBytesValuesSV(valueBlock.getNumDocs());
+ IntFunction<Object> resultExtractor = getResultExtractor(valueBlock);
+ byte[] defaultValue = (byte[]) _defaultValue;
+ int numDocs = valueBlock.getNumDocs();
+ for (int i = 0; i < numDocs; i++) {
+ Object result = null;
+ try {
+ result = resultExtractor.apply(i);
+ } catch (Exception ignored) {
+ }
+ if (result == null) {
+ if (_defaultValue != null) {
+ _bytesValuesSV[i] = defaultValue;
+ continue;
+ }
+ throw new IllegalArgumentException(
+ "Cannot resolve JSON path on some records. Consider setting a
default value.");
+ }
+ _bytesValuesSV[i] = PinotDataType.JSON.toBytes(result);
+ }
+ return _bytesValuesSV;
+ }
+
@Override
public int[][] transformToIntValuesMV(ValueBlock valueBlock) {
if (_storedType != DataType.INT) {
@@ -419,7 +512,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toInt(element, isBoolean);
}
@@ -459,7 +552,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toLong(element, isTimestamp);
}
@@ -498,7 +591,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toFloat(element);
}
@@ -537,7 +630,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toDouble(element);
}
@@ -576,7 +669,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toBigDecimal(element);
}
@@ -615,7 +708,7 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
throw new IllegalArgumentException(
"At least one of the resolved JSON arrays include nulls, which
is not supported in Pinot. "
- + "Consider setting a default value as the fourth argument
of json_extract_scalar.");
+ + "Consider setting a default value as the fourth argument
of " + _functionName + ".");
}
values[j] = toString(element);
}
@@ -703,21 +796,57 @@ public class JsonExtractScalarTransformFunction extends
BaseTransformFunction {
}
}
- private <T> IntFunction<T> getResultExtractor(ValueBlock valueBlock,
ParseContext parseContext) {
+ @SuppressWarnings("unchecked")
+ private <T> IntFunction<T> getResultExtractor(ValueBlock valueBlock,
ParseContext parseContext,
+ boolean useBigDecimal) {
if (_jsonFieldTransformFunction.getResultMetadata().getDataType() ==
DataType.BYTES) {
byte[][] jsonBytes =
_jsonFieldTransformFunction.transformToBytesValuesSV(valueBlock);
- return i -> parseContext.parseUtf8(jsonBytes[i]).read(_jsonPath);
+ IntFunction<T> jaywayExtractor = i ->
parseContext.parseUtf8(jsonBytes[i]).read(_jsonPath);
+ if (_simpleJsonPath == null) {
+ return jaywayExtractor;
+ }
+ SimpleJsonPath[] paths = {_simpleJsonPath};
+ Object[] result = new Object[1];
+ boolean earlyExit = _extractionMode == ExtractionMode.FIRST_MATCH;
+ return i -> {
+ if (FastJsonPathExtractor.canExtract(jsonBytes[i])) {
+ try {
+ FastJsonPathExtractor.extract(jsonBytes[i], paths, result,
useBigDecimal, earlyExit);
+ return (T) result[0];
+ } catch (Exception ignored) {
+ // Retry with Jayway so a fast-extractor failure cannot change the
existing result.
+ }
+ }
+ return jaywayExtractor.apply(i);
+ };
} else {
String[] jsonStrings =
_jsonFieldTransformFunction.transformToStringValuesSV(valueBlock);
- return i -> parseContext.parse(jsonStrings[i]).read(_jsonPath);
+ IntFunction<T> jaywayExtractor = i ->
parseContext.parse(jsonStrings[i]).read(_jsonPath);
+ if (_simpleJsonPath == null) {
+ return jaywayExtractor;
+ }
+ SimpleJsonPath[] paths = {_simpleJsonPath};
+ Object[] result = new Object[1];
+ boolean earlyExit = _extractionMode == ExtractionMode.FIRST_MATCH;
+ return i -> {
+ if (FastJsonPathExtractor.canExtract(jsonStrings[i])) {
+ try {
+ FastJsonPathExtractor.extract(jsonStrings[i], paths, result,
useBigDecimal, earlyExit);
+ return (T) result[0];
+ } catch (Exception ignored) {
+ // Retry with Jayway so a fast-extractor failure cannot change the
existing result.
+ }
+ }
+ return jaywayExtractor.apply(i);
+ };
}
}
private <T> IntFunction<T> getResultExtractor(ValueBlock valueBlock) {
- return getResultExtractor(valueBlock, JSON_PARSER_CONTEXT);
+ return getResultExtractor(valueBlock, JSON_PARSER_CONTEXT, false);
}
private <T> IntFunction<T> getResultExtractorWithBigDecimal(ValueBlock
valueBlock) {
- return getResultExtractor(valueBlock,
JSON_PARSER_CONTEXT_WITH_BIG_DECIMAL);
+ return getResultExtractor(valueBlock,
JSON_PARSER_CONTEXT_WITH_BIG_DECIMAL, true);
}
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
index 4c050f61b73..ca8e18b937c 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
@@ -130,6 +130,10 @@ public class TransformFunctionFactory {
typeToImplementation.put(TransformFunctionType.CAST,
CastTransformFunction.class);
typeToImplementation.put(TransformFunctionType.JSON_EXTRACT_SCALAR,
JsonExtractScalarTransformFunction.class);
+ typeToImplementation.put(TransformFunctionType.JSON_EXTRACT_SCALAR_FAST,
+ JsonExtractScalarTransformFunction.Fast.class);
+
typeToImplementation.put(TransformFunctionType.JSON_EXTRACT_SCALAR_FIRST_MATCH,
+ JsonExtractScalarTransformFunction.FirstMatch.class);
typeToImplementation.put(TransformFunctionType.JSON_EXTRACT_KEY,
JsonExtractKeyTransformFunction.class);
typeToImplementation.put(TransformFunctionType.TIME_CONVERT,
TimeConversionTransformFunction.class);
typeToImplementation.put(TransformFunctionType.DATE_TIME_CONVERT,
DateTimeConversionTransformFunction.class);
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
index abc996d2447..37efd3c5b10 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
@@ -46,7 +46,8 @@ public class FunctionRegistryTest {
// TODO: Support these functions
TransformFunctionType.IN, TransformFunctionType.NOT_IN,
TransformFunctionType.IS_TRUE,
TransformFunctionType.IS_NOT_TRUE, TransformFunctionType.IS_FALSE,
TransformFunctionType.IS_NOT_FALSE,
- TransformFunctionType.JSON_EXTRACT_SCALAR,
+ TransformFunctionType.JSON_EXTRACT_SCALAR,
TransformFunctionType.JSON_EXTRACT_SCALAR_FAST,
+ TransformFunctionType.JSON_EXTRACT_SCALAR_FIRST_MATCH,
TransformFunctionType.JSON_EXTRACT_KEY,
TransformFunctionType.TIME_CONVERT,
TransformFunctionType.DATE_TIME_CONVERT_WINDOW_HOP,
TransformFunctionType.ARRAY_LENGTH,
TransformFunctionType.ARRAY_AVERAGE, TransformFunctionType.ARRAY_MIN,
TransformFunctionType.ARRAY_MAX,
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunctionTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunctionTest.java
index 458d549a941..19ae0283a8f 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunctionTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunctionTest.java
@@ -22,12 +22,14 @@ import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Random;
+import javax.annotation.Nullable;
import org.apache.commons.io.FileUtils;
import org.apache.pinot.common.request.context.ExpressionContext;
import org.apache.pinot.common.request.context.RequestContextUtils;
@@ -37,6 +39,7 @@ import org.apache.pinot.spi.config.table.TableType;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.exception.BadQueryRequestException;
+import org.apache.pinot.spi.utils.BytesUtils;
import org.apache.pinot.spi.utils.JsonUtils;
import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
import org.apache.pinot.sql.parsers.SqlCompilationException;
@@ -49,16 +52,22 @@ import org.testng.annotations.Test;
public class JsonExtractScalarTransformFunctionTest extends
BaseTransformFunctionTest {
+ private static final String[] JSON_EXTRACT_SCALAR_FUNCTIONS = {
+ JsonExtractScalarTransformFunction.FUNCTION_NAME,
+ JsonExtractScalarTransformFunction.FAST_FUNCTION_NAME,
+ JsonExtractScalarTransformFunction.FIRST_MATCH_FUNCTION_NAME
+ };
protected File _baseDir;
@Test(dataProvider = "testJsonPathTransformFunction")
- public void testJsonPathTransformFunction(String expressionStr, DataType
resultsDataType, boolean isSingleValue) {
+ public void testJsonPathTransformFunction(String functionName, String
expressionStr, DataType resultsDataType,
+ boolean isSingleValue) {
ExpressionContext expression =
RequestContextUtils.getExpression(expressionStr);
TransformFunction transformFunction =
TransformFunctionFactory.get(expression, _dataSourceMap);
Assert.assertTrue(transformFunction instanceof
JsonExtractScalarTransformFunction);
- Assert.assertEquals(transformFunction.getName(),
JsonExtractScalarTransformFunction.FUNCTION_NAME);
+ Assert.assertEquals(transformFunction.getName(), functionName);
Assert.assertEquals(transformFunction.getResultMetadata().getDataType(),
resultsDataType);
Assert.assertEquals(transformFunction.getResultMetadata().isSingleValue(),
isSingleValue);
@@ -170,52 +179,53 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
// Test operating on both column and output of another transform (trim) to
avoid passing the evaluation down to the
// storage in order to test transformTransformedValuesToXXXValuesSV()
methods.
- for (String input : new String[]{JSON_COLUMN, String.format("trim(%s)",
JSON_COLUMN)}) {
- // Without default value
- testArguments.add(
- new Object[]{String.format("jsonExtractScalar(%s,'$.intSV','INT')",
input), DataType.INT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.intMV','INT_ARRAY')", input),
DataType.INT, false});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.longSV','LONG')", input),
DataType.LONG, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.floatSV','FLOAT')", input),
DataType.FLOAT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.doubleSV','DOUBLE')", input),
DataType.DOUBLE, true});
- testArguments.add(new Object[]{
-
String.format("jsonExtractScalar(%s,'$.bigDecimalSV','BIG_DECIMAL')", input),
DataType.BIG_DECIMAL, true
- });
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.stringSV','STRING')", input),
DataType.STRING, true});
-
- // With default value
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.intSV','INT',0)", input),
DataType.INT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.intMV','INT_ARRAY',0)", input),
DataType.INT, false});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.longSV','LONG',0)", input),
DataType.LONG, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.floatSV','FLOAT',0)", input),
DataType.FLOAT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.doubleSV','DOUBLE',0)", input),
DataType.DOUBLE, true});
- testArguments.add(new Object[]{
-
String.format("jsonExtractScalar(%s,'$.bigDecimalSV','BIG_DECIMAL',0)", input),
DataType.BIG_DECIMAL, true
- });
- testArguments.add(new Object[]{
- String.format("jsonExtractScalar(%s,'$.stringSV','STRING','null')",
input), DataType.STRING, true
- });
+ for (String functionName : JSON_EXTRACT_SCALAR_FUNCTIONS) {
+ for (String input : new String[]{JSON_COLUMN, String.format("trim(%s)",
JSON_COLUMN)}) {
+ // Without default value
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intSV','INT')", functionName, input),
DataType.INT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intMV','INT_ARRAY')", functionName,
input), DataType.INT, false});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.longSV','LONG')", functionName, input),
DataType.LONG, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.floatSV','FLOAT')", functionName, input),
DataType.FLOAT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.doubleSV','DOUBLE')", functionName,
input), DataType.DOUBLE, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.bigDecimalSV','BIG_DECIMAL')",
functionName, input), DataType.BIG_DECIMAL, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.stringSV','STRING')", functionName,
input), DataType.STRING, true});
+
+ // With default value
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intSV','INT',0)", functionName, input),
DataType.INT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intMV','INT_ARRAY',0)", functionName,
input), DataType.INT, false});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.longSV','LONG',0)", functionName, input),
DataType.LONG, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.floatSV','FLOAT',0)", functionName,
input), DataType.FLOAT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.doubleSV','DOUBLE',0)", functionName,
input), DataType.DOUBLE, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.bigDecimalSV','BIG_DECIMAL',0)",
functionName, input), DataType.BIG_DECIMAL,
+ true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.stringSV','STRING','null')", functionName,
input), DataType.STRING, true});
+ }
}
return testArguments.toArray(new Object[0][]);
}
@Test(dataProvider = "testDefaultValue")
- public void testDefaultValue(String expressionStr, DataType resultsDataType,
boolean isSingleValue) {
+ public void testDefaultValue(String functionName, String expressionStr,
DataType resultsDataType,
+ boolean isSingleValue) {
ExpressionContext expression =
RequestContextUtils.getExpression(expressionStr);
TransformFunction transformFunction =
TransformFunctionFactory.get(expression, _dataSourceMap);
Assert.assertTrue(transformFunction instanceof
JsonExtractScalarTransformFunction);
- Assert.assertEquals(transformFunction.getName(),
JsonExtractScalarTransformFunction.FUNCTION_NAME);
+ Assert.assertEquals(transformFunction.getName(), functionName);
Assert.assertEquals(transformFunction.getResultMetadata().getDataType(),
resultsDataType);
Assert.assertEquals(transformFunction.getResultMetadata().isSingleValue(),
isSingleValue);
@@ -280,23 +290,24 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
// Test operating on both column and output of another transform (trim) to
avoid passing the evaluation down to the
// storage in order to test transformTransformedValuesToXXXValuesSV()
methods.
- for (String input : new String[]{DEFAULT_JSON_COLUMN,
String.format("trim(%s)", DEFAULT_JSON_COLUMN)}) {
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.intSV','INT',0)", input),
DataType.INT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.intMV','INT_ARRAY',0)", input),
DataType.INT, false});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.longSV','LONG',0)", input),
DataType.LONG, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.floatSV','FLOAT',0)", input),
DataType.FLOAT, true});
- testArguments.add(
- new
Object[]{String.format("jsonExtractScalar(%s,'$.doubleSV','DOUBLE',0)", input),
DataType.DOUBLE, true});
- testArguments.add(new Object[]{
-
String.format("jsonExtractScalar(%s,'$.bigDecimalSV','BIG_DECIMAL',0)", input),
DataType.BIG_DECIMAL, true
- });
- testArguments.add(new Object[]{
- String.format("jsonExtractScalar(%s,'$.stringSV','STRING','null')",
input), DataType.STRING, true
- });
+ for (String functionName : JSON_EXTRACT_SCALAR_FUNCTIONS) {
+ for (String input : new String[]{DEFAULT_JSON_COLUMN,
String.format("trim(%s)", DEFAULT_JSON_COLUMN)}) {
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intSV','INT',0)", functionName, input),
DataType.INT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.intMV','INT_ARRAY',0)", functionName,
input), DataType.INT, false});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.longSV','LONG',0)", functionName, input),
DataType.LONG, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.floatSV','FLOAT',0)", functionName,
input), DataType.FLOAT, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.doubleSV','DOUBLE',0)", functionName,
input), DataType.DOUBLE, true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.bigDecimalSV','BIG_DECIMAL',0)",
functionName, input), DataType.BIG_DECIMAL,
+ true});
+ testArguments.add(new Object[]{functionName,
+ String.format("%s(%s,'$.stringSV','STRING','null')", functionName,
input), DataType.STRING, true});
+ }
}
return testArguments.toArray(new Object[0][]);
@@ -304,38 +315,40 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
@Test
public void testJsonPathTransformFunctionWithPredicate() {
- String jsonPathExpressionStr =
-
String.format("jsonExtractScalar(json,'[?($.stringSV==''%s'')]','STRING')",
_stringSVValues[0]);
- ExpressionContext expression =
RequestContextUtils.getExpression(jsonPathExpressionStr);
- TransformFunction transformFunction =
TransformFunctionFactory.get(expression, _dataSourceMap);
- Assert.assertTrue(transformFunction instanceof
JsonExtractScalarTransformFunction);
- Assert.assertEquals(transformFunction.getName(),
JsonExtractScalarTransformFunction.FUNCTION_NAME);
- // Note: transformToStringValuesSV() calls
IdentifierTransformFunction.transformToStringValuesSV() which in turns
- // call DataFetcher.readStringValues() which calls
DefaultJsonPathEvaluator.evaluateBlock() that parses String w/o
- // support for exact BigDecimal. Therefore, testing string parsing of
BigDecimal is disabled.
- String[] resultValues =
transformFunction.transformToStringValuesSV(_projectionBlock);
- for (int i = 0; i < NUM_ROWS; i++) {
- if (_stringSVValues[i].equals(_stringSVValues[0])) {
- try {
- final List<HashMap<String, Object>> resultMap =
JsonUtils.stringToObject(resultValues[i], List.class);
- Assert.assertEquals(_intSVValues[i], resultMap.get(0).get("intSV"));
- for (int j = 0; j < _intMVValues[i].length; j++) {
- Assert.assertEquals(_intMVValues[i][j], ((List)
resultMap.get(0).get("intMV")).get(j));
+ for (String functionName : JSON_EXTRACT_SCALAR_FUNCTIONS) {
+ String jsonPathExpressionStr =
+ String.format("%s(json,'[?($.stringSV==''%s'')]','STRING')",
functionName, _stringSVValues[0]);
+ ExpressionContext expression =
RequestContextUtils.getExpression(jsonPathExpressionStr);
+ TransformFunction transformFunction =
TransformFunctionFactory.get(expression, _dataSourceMap);
+ Assert.assertTrue(transformFunction instanceof
JsonExtractScalarTransformFunction);
+ Assert.assertEquals(transformFunction.getName(), functionName);
+ // Note: transformToStringValuesSV() calls
IdentifierTransformFunction.transformToStringValuesSV(), which calls
+ // DataFetcher.readStringValues() and
DefaultJsonPathEvaluator.evaluateBlock(). The evaluator parses String values
+ // without exact BigDecimal support, so testing string parsing of
BigDecimal is disabled.
+ String[] resultValues =
transformFunction.transformToStringValuesSV(_projectionBlock);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ if (_stringSVValues[i].equals(_stringSVValues[0])) {
+ try {
+ final List<HashMap<String, Object>> resultMap =
JsonUtils.stringToObject(resultValues[i], List.class);
+ Assert.assertEquals(_intSVValues[i],
resultMap.get(0).get("intSV"));
+ for (int j = 0; j < _intMVValues[i].length; j++) {
+ Assert.assertEquals(_intMVValues[i][j], ((List)
resultMap.get(0).get("intMV")).get(j));
+ }
+ Assert.assertEquals(_longSVValues[i],
resultMap.get(0).get("longSV"));
+ // Notes: since we use currently a mapper that parses exact big
decimals, doubles may get parsed as
+ // big decimals. Confirm this is a backward compatible change?
+ Assert.assertEquals(
+ Float.compare(_floatSVValues[i], ((Number)
resultMap.get(0).get("floatSV")).floatValue()), 0);
+ Assert.assertEquals(_doubleSVValues[i],
resultMap.get(0).get("doubleSV"));
+ // Disabled:
+ // Assert.assertEquals(_bigDecimalSVValues[i], (BigDecimal)
resultMap.get(0).get("bigDecimalSV"));
+ Assert.assertEquals(_stringSVValues[i],
resultMap.get(0).get("stringSV"));
+ } catch (IOException e) {
+ throw new RuntimeException();
}
- Assert.assertEquals(_longSVValues[i],
resultMap.get(0).get("longSV"));
- // Notes: since we use currently a mapper that parses exact big
decimals, doubles may get parsed as
- // big decimals. Confirm this is a backward compatible change?
- Assert.assertEquals(Float.compare(_floatSVValues[i], ((Number)
resultMap.get(0).get("floatSV")).floatValue()),
- 0);
- Assert.assertEquals(_doubleSVValues[i],
resultMap.get(0).get("doubleSV"));
- // Disabled:
- // Assert.assertEquals(_bigDecimalSVValues[i], (BigDecimal)
resultMap.get(0).get("bigDecimalSV"));
- Assert.assertEquals(_stringSVValues[i],
resultMap.get(0).get("stringSV"));
- } catch (IOException e) {
- throw new RuntimeException();
+ } else {
+ Assert.assertEquals(resultValues[i], "[]");
}
- } else {
- Assert.assertEquals(resultValues[i], "[]");
}
}
}
@@ -527,6 +540,10 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
new Object[]{String.format("jsonExtractScalar(%s,
\"$.store.book[0].author\", 'String')", JSON_COLUMN)},
new Object[]{String.format("jsonExtractScalar(%s,
'$.store.book[0].author', \"String\")", JSON_COLUMN)},
new Object[]{String.format("json_extract_scalar(%s,
\"$.store.book[0].author\", 'String','abc')", JSON_COLUMN)},
+ new Object[]{String.format("jsonExtractScalarFast(%s)", JSON_COLUMN)},
+ new Object[]{String.format("jsonExtractScalarFast(%s,
\"$.store.book[0].author\", 'String')", JSON_COLUMN)},
+ new Object[]{String.format("jsonExtractScalarFirstMatch(%s,
'$.store.book[0].author', \"String\")",
+ JSON_COLUMN)},
new Object[]{String.format("jsonExtractKey(%s, \"$.*\")",
JSON_COLUMN)},
new Object[]{String.format("json_extract_key(%s, \"$.*\")",
JSON_COLUMN)}};
//@formatter:on
@@ -660,14 +677,20 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
// -- Single-value (SV) tests --
- /// Runs `SELECT jsonExtractScalar(json, '$.v', resultsType) FROM testTable`
against a single-row table
- /// containing the given JSON document, and asserts the result for the
(always-duplicated) two
- /// expected rows.
+ /// Runs each JSON scalar extraction variant against a single-row table
containing the given JSON document, and
+ /// asserts the result for the (always-duplicated) two expected rows.
private void assertJsonExtractScalar(String json, String resultsType, Object
expectedValue) {
+ for (String functionName : JSON_EXTRACT_SCALAR_FUNCTIONS) {
+ assertJsonExtractScalar(functionName, json, DataType.JSON, resultsType,
null, expectedValue);
+ }
+ }
+
+ private void assertJsonExtractScalar(String functionName, Object json,
DataType inputType, String resultsType,
+ @Nullable String defaultValueSql, Object expectedValue) {
Schema schema = new Schema.SchemaBuilder()
.setSchemaName("testTable")
.setEnableColumnBasedNullHandling(true)
- .addDimensionField("json", DataType.JSON)
+ .addDimensionField("json", inputType)
.build();
TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE)
.setTableName("testTable")
@@ -677,10 +700,42 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
.withNullHandling(false)
.givenTable(schema, tableConfig)
.onFirstInstance(new Object[]{json})
- .whenQuery("SELECT jsonExtractScalar(json, '$.v', '" + resultsType +
"') FROM testTable")
+ .whenQuery("SELECT " + functionName + "(json, '$.v', '" + resultsType
+ "'"
+ + (defaultValueSql != null ? ", " + defaultValueSql : "") + ")
FROM testTable")
.thenResultIs(expectedRow, expectedRow);
}
+ @Test
+ public void testFastExtractionModes() {
+ String duplicateKeys = "{\"v\": 1, \"v\": 2}";
+ assertJsonExtractScalar(JsonExtractScalarTransformFunction.FUNCTION_NAME,
duplicateKeys, DataType.STRING, "INT",
+ null, 2);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FAST_FUNCTION_NAME,
duplicateKeys, DataType.STRING,
+ "INT", null, 2);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FIRST_MATCH_FUNCTION_NAME,
duplicateKeys,
+ DataType.STRING, "INT", null, 1);
+
+ String malformedTail = "{\"v\": 1, \"broken\": [}";
+ assertJsonExtractScalar(JsonExtractScalarTransformFunction.FUNCTION_NAME,
malformedTail, DataType.STRING, "INT",
+ "-1", -1);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FAST_FUNCTION_NAME,
malformedTail, DataType.STRING,
+ "INT", "-1", -1);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FIRST_MATCH_FUNCTION_NAME,
malformedTail,
+ DataType.STRING, "INT", "-1", 1);
+ }
+
+ @Test
+ public void testFastExtractionFromBytesWithBigDecimal() {
+ byte[] json = "{\"label\":\"crème
brûlée\",\"v\":12345678901234567890.123456789}"
+ .getBytes(StandardCharsets.UTF_8);
+ String expected = "12345678901234567890.123456789";
+ String hexEncodedJson = BytesUtils.toHexString(json);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FAST_FUNCTION_NAME,
hexEncodedJson, DataType.BYTES,
+ "BIG_DECIMAL", null, expected);
+
assertJsonExtractScalar(JsonExtractScalarTransformFunction.FIRST_MATCH_FUNCTION_NAME,
hexEncodedJson,
+ DataType.BYTES, "BIG_DECIMAL", null, expected);
+ }
+
@Test
public void testExtractBooleanAsNumeric() {
// JSON true / false coerces to 1 / 0 across all numeric result types —
matches PinotDataType's
@@ -807,10 +862,15 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
"12345678901234567890.123456789");
}
+ @Test
+ public void testExtractBytes() {
+ assertJsonExtractScalar("{\"v\": \"AAGl/w==\"}", "BYTES", "0001a5ff");
+ }
+
// -- Multi-value (MV) tests --
- /// Asserts that `SELECT jsonExtractScalar(json, '$.v', resultsType)` over a
single-row table with the
- /// given JSON document produces the given primitive-array result.
+ /// Asserts that each JSON scalar extraction variant produces the given
primitive-array result for the supplied
+ /// single-row JSON document.
private void assertJsonExtractMv(String json, String resultsType, Object
expectedArray) {
Schema schema = new Schema.SchemaBuilder()
.setSchemaName("testTable")
@@ -821,12 +881,14 @@ public class JsonExtractScalarTransformFunctionTest
extends BaseTransformFunctio
.setTableName("testTable")
.build();
Object[] expectedRow = new Object[]{expectedArray};
- FluentQueryTest.withBaseDir(_baseDir)
- .withNullHandling(false)
- .givenTable(schema, tableConfig)
- .onFirstInstance(new Object[]{json})
- .whenQuery("SELECT jsonExtractScalar(json, '$.v', '" + resultsType +
"') FROM testTable")
- .thenResultIs(expectedRow, expectedRow);
+ for (String functionName : JSON_EXTRACT_SCALAR_FUNCTIONS) {
+ FluentQueryTest.withBaseDir(_baseDir)
+ .withNullHandling(false)
+ .givenTable(schema, tableConfig)
+ .onFirstInstance(new Object[]{json})
+ .whenQuery("SELECT " + functionName + "(json, '$.v', '" +
resultsType + "') FROM testTable")
+ .thenResultIs(expectedRow, expectedRow);
+ }
}
@Test
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
index 95a9cceafae..6d77b3c69a4 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
@@ -23,6 +23,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.jayway.jsonpath.spi.cache.Cache;
import com.jayway.jsonpath.spi.cache.CacheProvider;
import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -58,6 +60,7 @@ public class JsonPathTest extends
CustomDataQueryClusterIntegrationTest {
// path (which visits every doc) return the same result set but follow
visibly different code paths.
private static final int NUM_DISTINCT_K1 = 100;
private static final String MY_MAP_STR_FIELD_NAME = "myMapStr";
+ private static final String MY_MAP_BYTES_FIELD_NAME = "myMapBytes";
private static final String MY_MAP_STR_K1_FIELD_NAME = "myMapStr_k1";
private static final String MY_MAP_STR_K2_FIELD_NAME = "myMapStr_k2";
/// Derived columns that exercise the opt-in fast scalar functions through
the ingestion transform path.
@@ -89,6 +92,7 @@ public class JsonPathTest extends
CustomDataQueryClusterIntegrationTest {
.setSchemaName(getTableName())
.addSingleValueDimension("myMap", DataType.STRING)
.addSingleValueDimension(MY_MAP_STR_FIELD_NAME, DataType.STRING)
+ .addSingleValueDimension(MY_MAP_BYTES_FIELD_NAME, DataType.BYTES)
.addSingleValueDimension(MY_MAP_STR_K1_FIELD_NAME, DataType.STRING)
.addSingleValueDimension(MY_MAP_STR_K2_FIELD_NAME, DataType.STRING)
.addSingleValueDimension(MY_MAP_STR_K1_FAST_FIELD_NAME,
DataType.STRING)
@@ -125,6 +129,8 @@ public class JsonPathTest extends
CustomDataQueryClusterIntegrationTest {
List<org.apache.avro.Schema.Field> fields = List.of(
new org.apache.avro.Schema.Field(MY_MAP_STR_FIELD_NAME,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.STRING),
null, null),
+ new org.apache.avro.Schema.Field(MY_MAP_BYTES_FIELD_NAME,
+ org.apache.avro.Schema.create(org.apache.avro.Schema.Type.BYTES),
null, null),
new org.apache.avro.Schema.Field(COMPLEX_MAP_STR_FIELD_NAME,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.STRING),
null, null)
);
@@ -136,7 +142,9 @@ public class JsonPathTest extends
CustomDataQueryClusterIntegrationTest {
map.put("k1", "value-k1-" + (i % NUM_DISTINCT_K1));
map.put("k2", "value-k2-" + i);
GenericData.Record record = new GenericData.Record(avroSchema);
- record.put(MY_MAP_STR_FIELD_NAME, JsonUtils.objectToString(map));
+ String myMapJson = JsonUtils.objectToString(map);
+ record.put(MY_MAP_STR_FIELD_NAME, myMapJson);
+ record.put(MY_MAP_BYTES_FIELD_NAME,
ByteBuffer.wrap(myMapJson.getBytes(StandardCharsets.UTF_8)));
Map<String, Object> complexMap = new HashMap<>();
complexMap.put("k1", "value-k1-" + i);
@@ -410,6 +418,35 @@ public class JsonPathTest extends
CustomDataQueryClusterIntegrationTest {
}
}
+ /// Query-time coverage for the typed transforms backed by the same fast
extractor. Both modes must match the
+ /// existing `jsonExtractScalar` transform on this clean, duplicate-free
data set.
+ @Test(dataProvider = "useBothQueryEngines")
+ void testFastJsonExtractScalarTransforms(boolean useMultiStageQueryEngine)
+ throws Exception {
+ setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+ String query = "SELECT jsonExtractScalar(myMapStr, '$.k1', 'STRING'), "
+ + "jsonExtractScalarFast(myMapStr, '$.k1', 'STRING'), "
+ + "jsonExtractScalarFirstMatch(myMapStr, '$.k1', 'STRING') FROM " +
getTableName() + " LIMIT 1000";
+ JsonNode rows = postQuery(query).get("resultTable").get("rows");
+ assertTrue(rows.size() > 0, "expected non-empty result set");
+ for (JsonNode row : rows) {
+ String jayway = row.get(0).asText();
+ assertEquals(row.get(1).asText(), jayway, "jsonExtractScalarFast must
equal jsonExtractScalar");
+ assertEquals(row.get(2).asText(), jayway, "jsonExtractScalarFirstMatch
must equal Jayway on clean data");
+ }
+
+ query = "SELECT jsonExtractScalar(myMapBytes, '$.k1', 'STRING'), "
+ + "jsonExtractScalarFast(myMapBytes, '$.k1', 'STRING'), "
+ + "jsonExtractScalarFirstMatch(myMapBytes, '$.k1', 'STRING') FROM " +
getTableName() + " LIMIT 1000";
+ rows = postQuery(query).get("resultTable").get("rows");
+ assertTrue(rows.size() > 0, "expected non-empty BYTES result set");
+ for (JsonNode row : rows) {
+ String jayway = row.get(0).asText();
+ assertEquals(row.get(1).asText(), jayway, "BYTES fast extraction must
equal jsonExtractScalar");
+ assertEquals(row.get(2).asText(), jayway, "BYTES first-match extraction
must equal Jayway on clean data");
+ }
+ }
+
@Test
public void testJsonPathCache() {
Cache cache = CacheProvider.getCache();
diff --git
a/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
b/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
index 606f79f6d12..0fe80bc4ceb 100644
---
a/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
+++
b/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
@@ -981,6 +981,14 @@ jsonextractscalar:
scalar: null
transform:
"org.apache.pinot.core.operator.transform.function.JsonExtractScalarTransformFunction"
udf: null
+jsonextractscalarfast:
+ scalar: null
+ transform:
"org.apache.pinot.core.operator.transform.function.JsonExtractScalarTransformFunction.Fast"
+ udf: null
+jsonextractscalarfirstmatch:
+ scalar: null
+ transform:
"org.apache.pinot.core.operator.transform.function.JsonExtractScalarTransformFunction.FirstMatch"
+ udf: null
jsonformat:
scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonFormat}"
transform: null
@@ -1007,6 +1015,14 @@ jsonpathdouble:
\ 3:
org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathDouble]}"
transform: null
udf: null
+jsonpathdoublefast:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathDoubleFast}"
+ transform: null
+ udf: null
+jsonpathdoublefirstmatch:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathDoubleFirstMatch}"
+ transform: null
+ udf: null
jsonpathexists:
scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathExists}"
transform: null
@@ -1016,11 +1032,27 @@ jsonpathlong:
\ 3: org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathLong]}"
transform: null
udf: null
+jsonpathlongfast:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathLongFast}"
+ transform: null
+ udf: null
+jsonpathlongfirstmatch:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathLongFirstMatch}"
+ transform: null
+ udf: null
jsonpathstring:
scalar: "ArgumentCountBasedScalarFunction{[2:
org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathString,\
\ 3:
org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathString]}"
transform: null
udf: null
+jsonpathstringfast:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathStringFast}"
+ transform: null
+ udf: null
+jsonpathstringfirstmatch:
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonPathStringFirstMatch}"
+ transform: null
+ udf: null
jsonstringtoarray:
scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.common.function.scalar.JsonFunctions.jsonStringToArray}"
transform: null
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index e3b87b197fa..6f22a19ae17 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -30,6 +30,7 @@ import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import org.apache.calcite.rel.RelDistribution;
import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.pinot.query.planner.PlannerUtils;
import org.apache.pinot.query.planner.physical.DispatchablePlanFragment;
import org.apache.pinot.query.planner.physical.DispatchableSubPlan;
@@ -70,6 +71,73 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
assertNotNull(dispatchableSubPlan);
}
+ @Test
+ public void testFastJsonExtractScalarTypeInference() {
+ RelDataType rowType = _queryEnvironment.compile(
+ "SELECT JSON_EXTRACT_SCALAR_FAST(hexToBytes(col1), '$.foo',
'BIG_DECIMAL'), "
+ + "JSON_EXTRACT_SCALAR(col1, '$.foo', 'LONG', -1), "
+ + "JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'BOOLEAN', FALSE), "
+ + "JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo', 'LONG', -1),
"
+ + "JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'DOUBLE_ARRAY'), "
+ + "JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo',
'BIG_DECIMAL_ARRAY'), "
+ + "JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'BOOLEAN_ARRAY'), "
+ + "JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo',
'TIMESTAMP_ARRAY'), "
+ + "JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'JSON') FROM a")
+ .getRelRoot().validatedRowType;
+ assertEquals(rowType.getFieldList().get(0).getType().getSqlTypeName(),
SqlTypeName.DECIMAL);
+ assertEquals(rowType.getFieldList().get(1).getType().getSqlTypeName(),
SqlTypeName.BIGINT);
+ assertEquals(rowType.getFieldList().get(2).getType().getSqlTypeName(),
SqlTypeName.BOOLEAN);
+ assertEquals(rowType.getFieldList().get(3).getType().getSqlTypeName(),
SqlTypeName.BIGINT);
+ RelDataType arrayType = rowType.getFieldList().get(4).getType();
+ assertEquals(arrayType.getSqlTypeName(), SqlTypeName.ARRAY);
+ assertEquals(arrayType.getComponentType().getSqlTypeName(),
SqlTypeName.DOUBLE);
+ arrayType = rowType.getFieldList().get(5).getType();
+ assertEquals(arrayType.getSqlTypeName(), SqlTypeName.ARRAY);
+ assertEquals(arrayType.getComponentType().getSqlTypeName(),
SqlTypeName.DECIMAL);
+ arrayType = rowType.getFieldList().get(6).getType();
+ assertEquals(arrayType.getSqlTypeName(), SqlTypeName.ARRAY);
+ assertEquals(arrayType.getComponentType().getSqlTypeName(),
SqlTypeName.BOOLEAN);
+ arrayType = rowType.getFieldList().get(7).getType();
+ assertEquals(arrayType.getSqlTypeName(), SqlTypeName.ARRAY);
+ assertEquals(arrayType.getComponentType().getSqlTypeName(),
SqlTypeName.TIMESTAMP);
+ assertEquals(rowType.getFieldList().get(8).getType().getSqlTypeName(),
SqlTypeName.VARCHAR);
+
+ // A non-literal resultsType or defaultValue is rejected during
validation. jsonPath is deliberately not in this
+ // list -- see testJsonExtractScalarAcceptsFoldableJsonPath.
+ List<String> invalidQueries = List.of(
+ "SELECT JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'LONG', col3) FROM a",
+ "SELECT JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo', col2, -1) FROM
a");
+ for (String invalidQuery : invalidQueries) {
+ Throwable invalidOperand = expectThrows(RuntimeException.class, () ->
_queryEnvironment.compile(invalidQuery));
+
assertTrue(Throwables.getStackTraceAsString(invalidOperand).contains("Cannot
apply 'JSONEXTRACTSCALAR"),
+ "Unexpected failure for " + invalidQuery + ": " +
Throwables.getStackTraceAsString(invalidOperand));
+ }
+ }
+
+ /// `jsonPath` must resolve to a literal, but the operand type checker
deliberately does not demand a literal
+ /// `SqlNode` in that position: operand checking runs before
`PinotEvaluateLiteralRule` folds constant
+ /// expressions, so an argument such as `CONCAT('$.', 'foo')` folds to a
literal and plans and executes
+ /// correctly. Requiring [org.apache.calcite.sql.type.OperandTypes#LITERAL]
there would reject these queries,
+ /// which plan and execute successfully on master. Regression guard for all
three JSON scalar transforms, which
+ /// share one operand checker;
`QueryRunnerTest#provideTestSqlWithExecutionException` covers the end-to-end
half,
+ /// asserting that a folded path is actually applied on the leaf stage.
+ ///
+ /// `resultsType` is checked separately in
[#testFastJsonExtractScalarTypeInference], since it must stay a literal
+ /// for return-type inference to see it.
+ @Test
+ public void testJsonExtractScalarAcceptsFoldableJsonPath() {
+ List<String> functions =
+ List.of("JSON_EXTRACT_SCALAR", "JSON_EXTRACT_SCALAR_FAST",
"JSON_EXTRACT_SCALAR_FIRST_MATCH");
+ for (String function : functions) {
+ for (String path : List.of("CONCAT('$.', 'foo')", "CAST('$.foo' AS
VARCHAR)", "UPPER('$.foo')")) {
+ String query = "SELECT " + function + "(col1, " + path + ", 'INT')
FROM a";
+ // The return type must still come from the literal resultsType rather
than falling back to VARCHAR.
+
assertEquals(_queryEnvironment.compile(query).getRelRoot().validatedRowType.getFieldList().get(0).getType()
+ .getSqlTypeName(), SqlTypeName.INTEGER, query);
+ }
+ }
+ }
+
@Test
public void testPolymorphicArithmeticScalarFunctionsPlanQuery() {
DispatchableSubPlan dispatchableSubPlan = _queryEnvironment.planQuery(
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryEnvironmentTestBase.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryEnvironmentTestBase.java
index c82c255339b..254a9359567 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryEnvironmentTestBase.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryEnvironmentTestBase.java
@@ -251,6 +251,13 @@ public class QueryEnvironmentTestBase {
new Object[]{"SELECT JSON_EXTRACT_SCALAR(col1, '$.foo', 'FLOAT_ARRAY')
FROM a"},
new Object[]{"SELECT JSON_EXTRACT_SCALAR(col1, '$.foo',
'DOUBLE_ARRAY') FROM a"},
new Object[]{"SELECT JSON_EXTRACT_SCALAR(col1, '$.foo',
'STRING_ARRAY') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FAST(col1, '$.foo',
'BIG_DECIMAL') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FAST(col1, '$.foo',
'DOUBLE_ARRAY') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FAST(col1, '$.foo',
'BOOLEAN_ARRAY') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FAST(col1, '$.foo', 'JSON')
FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo',
'LONG', '0') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo',
'STRING_ARRAY') FROM a"},
+ new Object[]{"SELECT JSON_EXTRACT_SCALAR_FIRST_MATCH(col1, '$.foo',
'TIMESTAMP_ARRAY') FROM a"},
new Object[]{"SELECT ts_timestamp FROM a WHERE ts_timestamp BETWEEN
TIMESTAMP '2016-01-01 00:00:00' AND "
+ "TIMESTAMP '2016-01-01 10:00:00'"},
new Object[]{"SELECT ts_timestamp FROM a WHERE ts_timestamp >=
CAST(1454284798000 AS TIMESTAMP)"},
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
index 6059b8031aa..bf4a7095926 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/QueryRunnerTest.java
@@ -331,6 +331,23 @@ public class QueryRunnerTest extends QueryRunnerTestBase {
// - checked "Illegal Json Path" as col1 is not actually a json string,
but the call is correctly triggered.
testCases.add(
new Object[]{"SELECT CAST(jsonExtractScalar(col1, 'path', 'INT') AS
INT) FROM a", "Cannot resolve JSON path"});
+ // - a constant-foldable jsonPath must be folded by
PinotEvaluateLiteralRule and then applied like a literal.
+ // Reaching "Cannot resolve JSON path" (rather than ParserUtils'
"single-quoted literal values") is what
+ // proves the fold happened, so this pins the reason jsonPath does
not require a literal in
+ // TransformFunctionType#jsonExtractScalarOperandTypeChecker.
+ testCases.add(new Object[]{
+ "SELECT CAST(jsonExtractScalar(col1, CONCAT('pa', 'th'), 'INT') AS
INT) FROM a", "Cannot resolve JSON path"});
+ // - the flip side: a jsonPath that cannot fold to a literal is still
rejected, on the leaf stage rather than
+ // during validation. Covers all three variants, which share the
operand type checker.
+ for (String jsonExtractScalar : new String[]{
+ "jsonExtractScalar", "jsonExtractScalarFast",
"jsonExtractScalarFirstMatch"
+ }) {
+ testCases.add(new Object[]{
+ "SELECT " + jsonExtractScalar + "(col1, col2, 'INT') FROM a",
+ "Expect the 2nd and 3rd arguments of transform function: " +
jsonExtractScalar
+ + "(jsonFieldName, 'jsonPath', 'resultsType', ['defaultValue'])
to be single-quoted literal values"
+ });
+ }
// - checked function cannot be found b/c there's no intermediate stage
impl for json_extract_scalar
testCases.add(new Object[]{
"SELECT CAST(json_extract_scalar(a.col1, b.col2, 'INT') AS INT) FROM a
JOIN b ON a.col1 = b.col1",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]