diff --git a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java index eac8e4e4af4a..25b135d3a783 100644 --- a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java +++ b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java @@ -19,7 +19,9 @@ package org.apache.pinot.common.function.scalar; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.jayway.jsonpath.Configuration; @@ -31,6 +33,7 @@ import com.jayway.jsonpath.spi.json.JacksonJsonProvider; import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider; import java.io.IOException; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -39,7 +42,11 @@ import org.apache.commons.lang3.StringUtils; import org.apache.pinot.common.function.JsonPathCache; import org.apache.pinot.spi.annotations.ScalarFunction; +import org.apache.pinot.spi.data.FieldSpec.DataType; +import org.apache.pinot.spi.utils.BooleanUtils; +import org.apache.pinot.spi.utils.BytesUtils; import org.apache.pinot.spi.utils.JsonUtils; +import org.apache.pinot.spi.utils.TimestampUtils; /** @@ -64,6 +71,13 @@ private JsonFunctions() { .mappingProvider(new JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS) .build()); + // Mirrors JsonExtractScalarTransformFunction's BigDecimal-preserving parser: BIG_DECIMAL / STRING / JSON + // extraction reads JSON floats as BigDecimal to avoid precision loss. + private static final ParseContext PARSE_CONTEXT_WITH_BIG_DECIMAL = JsonPath.using( + new Configuration.ConfigurationBuilder().jsonProvider(new JacksonJsonProvider( + new ObjectMapper().configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true))) + .mappingProvider(new JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS).build()); + // JsonPath context for extracting keys (paths) private static final ParseContext KEY_PARSE_CONTEXT = JsonPath.using( new Configuration.ConfigurationBuilder().jsonProvider(new JacksonJsonProvider()) @@ -394,6 +408,306 @@ public static Object jsonExtractObject(@Nullable Object object) { return null; } + /** + * Extract a scalar (or scalar-array) value from a JSON document and coerce it to {@code resultsType}. + *

Scalar-function counterpart of the {@code jsonExtractScalar} transform (see + * {@link org.apache.pinot.core.operator.transform.function.JsonExtractScalarTransformFunction}), so that + * {@code json_extract_scalar(...)} resolves in the multi-stage engine and in ad-hoc scalar contexts. + * {@code resultsType} is a Pinot {@link DataType} name, optionally suffixed with {@code _ARRAY} for a + * multi-value result. Supported types are + * {@code INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/BOOLEAN/TIMESTAMP/STRING/JSON/BYTES} and the + * {@code INT/LONG/FLOAT/DOUBLE/BIG_DECIMAL/STRING} array variants. + *

Coercion mirrors the transform exactly: {@code BOOLEAN} is returned as its stored {@code INT} (0/1), + * {@code TIMESTAMP} as epoch millis (numeric values as-is, strings via ISO-8601), {@code BIG_DECIMAL} / + * {@code STRING} / {@code JSON} use a BigDecimal-preserving parser. Without a default value an unresolved + * single-value path throws; a multi-value path yields an empty array, but a {@code null} element inside a + * resolved array still throws. A malformed JSON document is treated as unresolved. + */ + @ScalarFunction + public static Object jsonExtractScalar(Object jsonInput, String jsonPath, String resultsType) { + return jsonExtractScalar(jsonInput, jsonPath, resultsType, null); + } + + /** + * See {@link #jsonExtractScalar(Object, String, String)}. {@code defaultValue} is returned (coerced to + * {@code resultsType}) when the path resolves to {@code null} or the document is malformed. + */ + @ScalarFunction(nullableParameters = true) + public static Object jsonExtractScalar(@Nullable Object jsonInput, String jsonPath, String resultsType, + @Nullable Object defaultValue) { + String type = resultsType.toUpperCase(); + boolean isSingleValue = !type.endsWith("_ARRAY"); + DataType dataType; + try { + dataType = DataType.valueOf(isSingleValue ? type : type.substring(0, type.length() - 6)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(unsupportedResultsTypeMessage(resultsType)); + } + // BIG_DECIMAL / STRING / JSON must read floats as BigDecimal to preserve precision, matching the transform. + boolean useBigDecimal = + dataType == DataType.BIG_DECIMAL || dataType == DataType.STRING || dataType == DataType.JSON; + if (isSingleValue) { + Object value = readJsonPathValue(jsonInput, jsonPath, useBigDecimal); + if (value == null) { + if (defaultValue != null) { + return coerceScalar(defaultValue, dataType); + } + throw new IllegalArgumentException( + "Cannot resolve JSON path on some records. Consider setting a default value."); + } + return coerceScalar(value, dataType); + } + return coerceScalarArray(readJsonPathArray(jsonInput, jsonPath, useBigDecimal), dataType, defaultValue); + } + + @Nullable + private static Object readJsonPathValue(@Nullable Object jsonInput, String jsonPath, boolean useBigDecimal) { + if (jsonInput == null) { + return null; + } + try { + ParseContext parseContext = useBigDecimal ? PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT; + if (jsonInput instanceof String) { + return parseContext.parse((String) jsonInput).read(jsonPath, NO_PREDICATES); + } + return parseContext.parse(jsonInput).read(jsonPath, NO_PREDICATES); + } catch (Exception e) { + // Malformed JSON (e.g. a plain-text row) is treated as unresolved, mirroring the transform which swallows + // per-row extraction errors; the caller then applies the default or throws. + return null; + } + } + + @Nullable + private static Object[] readJsonPathArray(@Nullable Object jsonInput, String jsonPath, boolean useBigDecimal) { + if (jsonInput == null) { + return null; + } + try { + ParseContext parseContext = useBigDecimal ? PARSE_CONTEXT_WITH_BIG_DECIMAL : PARSE_CONTEXT; + Object read = jsonInput instanceof String ? parseContext.parse((String) jsonInput).read(jsonPath, NO_PREDICATES) + : parseContext.parse(jsonInput).read(jsonPath, NO_PREDICATES); + return convertObjectToArray(read); + } catch (Exception e) { + return null; + } + } + + private static Object coerceScalar(Object value, DataType dataType) { + switch (dataType) { + case INT: + return toInt(value, false); + case BOOLEAN: + return toInt(value, true); + case LONG: + return toLong(value, false); + case TIMESTAMP: + return toLong(value, true); + case FLOAT: + return toFloat(value); + case DOUBLE: + return toDouble(value); + case BIG_DECIMAL: + return toBigDecimal(value); + case STRING: + case JSON: + return toStringValue(value); + case BYTES: + return BytesUtils.toBytes(value.toString()); + default: + throw new IllegalArgumentException(unsupportedResultsTypeMessage(dataType.name())); + } + } + + private static Object coerceScalarArray(@Nullable Object[] array, DataType dataType, @Nullable Object defaultValue) { + switch (dataType) { + case INT: + return toIntArray(array, defaultValue); + case LONG: + return toLongArray(array, defaultValue); + case FLOAT: + return toFloatArray(array, defaultValue); + case DOUBLE: + return toDoubleArray(array, defaultValue); + case BIG_DECIMAL: + return toBigDecimalArray(array, defaultValue); + case STRING: + return toStringArray(array, defaultValue); + default: + throw new IllegalArgumentException(unsupportedResultsTypeMessage(dataType.name() + "_ARRAY")); + } + } + + /** + * Resolve a single array element: pass through a non-null element, substitute the default when the element is + * {@code null}, or throw when a null element has no default - matching the transform's MV semantics. + */ + private static Object resolveArrayElement(@Nullable Object element, @Nullable Object defaultValue) { + if (element != null) { + return element; + } + if (defaultValue != null) { + return defaultValue; + } + 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."); + } + + private static int[] toIntArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new int[0]; + } + int[] values = new int[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toInt(resolveArrayElement(array[i], defaultValue), false); + } + return values; + } + + private static long[] toLongArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new long[0]; + } + long[] values = new long[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toLong(resolveArrayElement(array[i], defaultValue), false); + } + return values; + } + + private static float[] toFloatArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new float[0]; + } + float[] values = new float[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toFloat(resolveArrayElement(array[i], defaultValue)); + } + return values; + } + + private static double[] toDoubleArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new double[0]; + } + double[] values = new double[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toDouble(resolveArrayElement(array[i], defaultValue)); + } + return values; + } + + private static BigDecimal[] toBigDecimalArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new BigDecimal[0]; + } + BigDecimal[] values = new BigDecimal[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toBigDecimal(resolveArrayElement(array[i], defaultValue)); + } + return values; + } + + private static String[] toStringArray(@Nullable Object[] array, @Nullable Object defaultValue) { + if (array == null) { + return new String[0]; + } + String[] values = new String[array.length]; + for (int i = 0; i < array.length; i++) { + values[i] = toStringValue(resolveArrayElement(array[i], defaultValue)); + } + return values; + } + + private static int toInt(Object value, boolean isBoolean) { + if (isBoolean) { + if (value instanceof Boolean) { + return (Boolean) value ? 1 : 0; + } + // For BOOLEAN result, follow Pinot's numeric convention: any non-zero number is true. + if (value instanceof Number) { + return ((Number) value).doubleValue() != 0 ? 1 : 0; + } + // String fallback: BooleanUtils.toInt accepts "true" / "TRUE" / "1". + return BooleanUtils.toInt(value.toString()); + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof Boolean) { + return (Boolean) value ? 1 : 0; + } + return Integer.parseInt(value.toString()); + } + + private static long toLong(Object value, boolean isTimestamp) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (isTimestamp) { + return TimestampUtils.toMillisSinceEpoch(value.toString()); + } + if (value instanceof Boolean) { + return (Boolean) value ? 1L : 0L; + } + try { + // pinot-common cannot depend on pinot-core's NumberUtils#parseJsonLong; BigDecimal reproduces its + // truncate-toward-zero and exponent handling for JSON numeric strings ("1.9" -> 1, "1E1" -> 10). + return new BigDecimal(value.toString().trim()).longValue(); + } catch (NumberFormatException e) { + throw new NumberFormatException("For input string: \"" + value + "\""); + } + } + + private static float toFloat(Object value) { + if (value instanceof Number) { + return ((Number) value).floatValue(); + } + if (value instanceof Boolean) { + return (Boolean) value ? 1f : 0f; + } + return Float.parseFloat(value.toString()); + } + + private static double toDouble(Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + if (value instanceof Boolean) { + return (Boolean) value ? 1d : 0d; + } + return Double.parseDouble(value.toString()); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Boolean) { + return (Boolean) value ? BigDecimal.ONE : BigDecimal.ZERO; + } + return new BigDecimal(value.toString()); + } + + private static String toStringValue(Object value) { + if (value instanceof String) { + return (String) value; + } + try { + return JsonUtils.objectToString(value); + } catch (JsonProcessingException e) { + throw new RuntimeException("Caught exception while serializing JSON value: " + value, e); + } + } + + private static String unsupportedResultsTypeMessage(String resultsType) { + return String.format( + "Unsupported results type: %s for jsonExtractScalar 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); + } + private static void setValuesToMap(String keyColumnName, String valueColumnName, Object obj, Map result) { if (obj instanceof Map) { diff --git a/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java b/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java index 96d83b951190..72a9dd961cf9 100644 --- a/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java +++ b/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.jayway.jsonpath.InvalidJsonException; import java.io.IOException; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -36,6 +37,7 @@ import static org.testng.Assert.assertNull; import static org.testng.Assert.assertSame; import static org.testng.Assert.assertTrue; +import static org.testng.Assert.expectThrows; public class JsonFunctionsTest { @@ -794,4 +796,119 @@ public void testJsonExtractKeySpecialCharacters() Assert.assertTrue(jsonPathResult.contains("$['field_with_underscores']")); Assert.assertTrue(jsonPathResult.contains("$['field with spaces']")); } + + private static String scalarSampleJson() { + return "{" + + "\"i\":42,\"l\":9999999999,\"f\":1.5,\"d\":2.5,\"s\":\"hi\",\"b\":true," + + "\"num5\":5,\"zero\":0,\"bstr1\":\"1\"," + + "\"tnum\":1514805173000,\"tiso\":\"2018-01-01T11:12:53Z\"," + + "\"hp\":0.1234567890123456789,\"obj\":{\"k\":\"v\"}," + + "\"arr\":[1,2,3],\"arrnull\":[1,null,3],\"lstr\":\"1.234\",\"lexp\":\"1E1\"}"; + } + + @Test + public void testJsonExtractScalarSingleValueCoercions() { + String json = scalarSampleJson(); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.i", "INT"), 42); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.l", "LONG"), 9999999999L); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.f", "FLOAT"), 1.5f); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.d", "DOUBLE"), 2.5d); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.s", "STRING"), "hi"); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.obj", "STRING"), "{\"k\":\"v\"}"); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.obj", "JSON"), "{\"k\":\"v\"}"); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.i", "BIG_DECIMAL"), new BigDecimal("42")); + // BOOLEAN follows Pinot's numeric convention (non-zero number is true) and is returned as stored INT (0/1). + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.b", "BOOLEAN"), 1); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.num5", "BOOLEAN"), 1); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.zero", "BOOLEAN"), 0); + // The string "1" must go through BooleanUtils.toInt; Boolean.parseBoolean("1") would wrongly yield 0. + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.bstr1", "BOOLEAN"), 1); + // TIMESTAMP: numeric epoch as-is; ISO-8601 string via TimestampUtils (Long.parseLong would throw). + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.tnum", "TIMESTAMP"), 1514805173000L); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.tiso", "TIMESTAMP"), 1514805173000L); + // LONG from numeric strings uses BigDecimal truncation/exponent handling; Long.parseLong would throw. + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.lstr", "LONG"), 1L); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.lexp", "LONG"), 10L); + } + + @Test + public void testJsonExtractScalarBigDecimalPreservesPrecision() { + // Default float parsing collapses to a double (0.12345678901234568); the BigDecimal parser must not. + Object result = JsonFunctions.jsonExtractScalar(scalarSampleJson(), "$.hp", "BIG_DECIMAL"); + assertEquals(((BigDecimal) result).compareTo(new BigDecimal("0.1234567890123456789")), 0); + } + + @Test + public void testJsonExtractScalarBytes() { + assertEquals((byte[]) JsonFunctions.jsonExtractScalar("{\"h\":\"0a0b\"}", "$.h", "BYTES"), + new byte[]{0x0a, 0x0b}); + } + + @Test + public void testJsonExtractScalarReturnsDefaultWhenUnresolved() { + String json = scalarSampleJson(); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "INT", -1), -1); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "LONG", -1L), -1L); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "DOUBLE", -1d), -1d); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "STRING", "def"), "def"); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "BOOLEAN", 1), 1); + assertEquals(JsonFunctions.jsonExtractScalar(json, "$.missing", "BIG_DECIMAL", BigDecimal.ONE), BigDecimal.ONE); + } + + @DataProvider(name = "unresolvedThrowTypes") + public Object[][] unresolvedThrowTypes() { + return new Object[][]{{"INT"}, {"LONG"}, {"DOUBLE"}, {"STRING"}, {"BIG_DECIMAL"}}; + } + + @Test(dataProvider = "unresolvedThrowTypes") + public void testJsonExtractScalarThrowsWhenUnresolvedWithoutDefault(String type) { + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, + () -> JsonFunctions.jsonExtractScalar(scalarSampleJson(), "$.missing", type)); + assertEquals(e.getMessage(), "Cannot resolve JSON path on some records. Consider setting a default value."); + } + + @Test + public void testJsonExtractScalarTreatsMalformedJsonAsUnresolved() { + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, + () -> JsonFunctions.jsonExtractScalar("not json", "$.x", "STRING")); + assertEquals(e.getMessage(), "Cannot resolve JSON path on some records. Consider setting a default value."); + assertEquals(JsonFunctions.jsonExtractScalar("not json", "$.x", "STRING", "def"), "def"); + } + + @Test + public void testJsonExtractScalarArrays() { + String json = scalarSampleJson(); + assertEquals((int[]) JsonFunctions.jsonExtractScalar(json, "$.arr", "INT_ARRAY"), new int[]{1, 2, 3}); + // An unresolved multi-value path yields an empty array and never throws. + assertEquals((int[]) JsonFunctions.jsonExtractScalar(json, "$.missing", "INT_ARRAY"), new int[0]); + assertEquals((int[]) JsonFunctions.jsonExtractScalar("not json", "$.x", "INT_ARRAY"), new int[0]); + // A null element is filled from the default when present, otherwise it throws. + assertEquals((int[]) JsonFunctions.jsonExtractScalar(json, "$.arrnull", "INT_ARRAY", 0), new int[]{1, 0, 3}); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, + () -> JsonFunctions.jsonExtractScalar(json, "$.arrnull", "INT_ARRAY")); + assertEquals(e.getMessage(), + "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."); + assertEquals((String[]) JsonFunctions.jsonExtractScalar("{\"a\":[\"x\",\"y\"]}", "$.a", "STRING_ARRAY"), + new String[]{"x", "y"}); + } + + @Test + public void testJsonExtractScalarUnsupportedType() { + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, + () -> JsonFunctions.jsonExtractScalar(scalarSampleJson(), "$.i", "FOO")); + assertTrue(e.getMessage().startsWith("Unsupported results type: FOO for jsonExtractScalar function.")); + } + + @Test + public void testJsonExtractScalarLeafParseFailures() { + expectThrows(NumberFormatException.class, + () -> JsonFunctions.jsonExtractScalar("{\"x\":\"abc\"}", "$.x", "INT")); + NumberFormatException nfe = expectThrows(NumberFormatException.class, + () -> JsonFunctions.jsonExtractScalar("{\"x\":\"abc\"}", "$.x", "LONG")); + assertEquals(nfe.getMessage(), "For input string: \"abc\""); + IllegalArgumentException iae = expectThrows(IllegalArgumentException.class, + () -> JsonFunctions.jsonExtractScalar("{\"x\":\"nope\"}", "$.x", "TIMESTAMP")); + assertTrue(iae.getMessage().startsWith("Invalid timestamp: 'nope'")); + } } 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 6059b8031aa1..18a471018053 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,11 +331,6 @@ protected Iterator provideTestSqlWithExecutionException() { // - 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"}); - // - 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", - "Unsupported function: JSONEXTRACTSCALAR" - }); // Positive int keys (only included ones that will be parsed for this query) for (String key : new String[]{