From 9e4445029411b64460603ad6f62537c391bcb431 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 20 Sep 2026 09:45:22 -0500 Subject: [PATCH] [CALCITE-7801] JSON_VALUE(..., RETURNING DOUBLE) throws ClassCastException when the JSON number is an integer --- .../adapter/enumerable/RexImpTable.java | 65 ++++- .../apache/calcite/runtime/JsonFunctions.java | 96 ++++++- .../apache/calcite/runtime/SqlFunctions.java | 189 ++++++++++++++ .../apache/calcite/util/BuiltInMethod.java | 7 +- .../calcite/runtime/SqlFunctionsCastTest.java | 167 ++++++++++++ .../org/apache/calcite/test/JdbcTest.java | 27 +- .../calcite/test/SqlJsonFunctionsTest.java | 13 +- site/_docs/reference.md | 13 +- .../apache/calcite/test/SqlOperatorTest.java | 243 +++++++++++++++++- 9 files changed, 781 insertions(+), 39 deletions(-) create mode 100644 core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java diff --git a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java index 6ef0182b301c..3a16d620bea8 100644 --- a/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java +++ b/core/src/main/java/org/apache/calcite/adapter/enumerable/RexImpTable.java @@ -3364,6 +3364,40 @@ static Expression call(Method method, @Nullable Expression target, } } + /** Base class for the implementors of {@code JSON_VALUE} and + * {@code JSON_QUERY}. */ + private abstract static class JsonImplementor extends MethodImplementor { + JsonImplementor(Method method) { + super(method, NullPolicy.ARG0, false); + } + + /** Calls the runtime method with {@code operands}, followed by the + * {@link SqlTypeName}, precision and scale of {@code returningType} and + * the rounding mode that a {@code CAST} to it would use. + * + *

A null {@code returningType} is passed as + * {@link SqlTypeName#ANY}, meaning no conversion. */ + Expression callWithReturningType(RexToLixTranslator translator, + List operands, @Nullable RelDataType returningType) { + operands.add( + Expressions.constant(returningType == null ? SqlTypeName.ANY + : returningType.getSqlTypeName())); + operands.add( + Expressions.constant( + returningType == null ? -1 : returningType.getPrecision())); + operands.add( + Expressions.constant( + returningType == null ? -1 : returningType.getScale())); + operands.add( + Expressions.constant( + translator.typeFactory.getTypeSystem().roundingMode())); + return Expressions.call( + Expressions.new_(method.getDeclaringClass()), + method, + EnumUtils.fromInternal(method.getParameterTypes(), operands)); + } + } + /** * Implementor for JSON_VALUE function, convert to solid format * "JSON_VALUE(json_doc, path, empty_behavior, empty_default, error_behavior, error default)" @@ -3372,9 +3406,9 @@ static Expression call(Method method, @Nullable Expression target, *

We should avoid this when we support * variable arguments function. */ - private static class JsonValueImplementor extends MethodImplementor { + private static class JsonValueImplementor extends JsonImplementor { JsonValueImplementor(Method method) { - super(method, NullPolicy.ARG0, false); + super(method); } @Override Expression implementSafe(RexToLixTranslator translator, @@ -3421,20 +3455,16 @@ private static class JsonValueImplementor extends MethodImplementor { newOperands.add(defaultValueOnEmpty); newOperands.add(errorBehavior); newOperands.add(defaultValueOnError); - List argValueList0 = - EnumUtils.fromInternal(method.getParameterTypes(), newOperands); - final Expression target = - Expressions.new_(method.getDeclaringClass()); - return Expressions.call(target, method, argValueList0); + return callWithReturningType(translator, newOperands, call.getType()); } } /** * Implementor for JSON_QUERY function. Passes the jsonize flag depending on the output type. */ - private static class JsonQueryImplementor extends MethodImplementor { + private static class JsonQueryImplementor extends JsonImplementor { JsonQueryImplementor(Method method) { - super(method, NullPolicy.ARG0, false); + super(method); } @Override Expression implementSafe(RexToLixTranslator translator, @@ -3449,11 +3479,18 @@ private static class JsonQueryImplementor extends MethodImplementor { } newOperands.add(jsonize); - List argValueList0 = - EnumUtils.fromInternal(method.getParameterTypes(), newOperands); - final Expression target = - Expressions.new_(method.getDeclaringClass()); - return Expressions.call(target, method, argValueList0); + // How deeply an array RETURNING clause nests, and the type of its + // innermost elements. A clause that gives no array type nests zero + // deep, and its element type is passed as null, for no conversion. + RelDataType elementType = call.getType(); + int arrayDepth = 0; + while (elementType.getSqlTypeName() == SqlTypeName.ARRAY) { + elementType = requireNonNull(elementType.getComponentType()); + ++arrayDepth; + } + newOperands.add(Expressions.constant(arrayDepth)); + return callWithReturningType(translator, newOperands, + arrayDepth == 0 ? null : elementType); } } diff --git a/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java b/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java index f5ce8dc479bd..851b824d4564 100644 --- a/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java @@ -22,6 +22,7 @@ import org.apache.calcite.sql.SqlJsonQueryEmptyOrErrorBehavior; import org.apache.calcite.sql.SqlJsonQueryWrapperBehavior; import org.apache.calcite.sql.SqlJsonValueEmptyOrErrorBehavior; +import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.util.Util; import com.fasterxml.jackson.annotation.JsonValue; @@ -43,6 +44,7 @@ import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; import org.checkerframework.checker.nullness.qual.Nullable; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -243,13 +245,21 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonValueEmptyOrErrorBehavior emptyBehavior, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, - Object defaultValueOnError) { + Object defaultValueOnError, + SqlTypeName returningType, + int precision, + int scale, + RoundingMode roundingMode) { return jsonValue( jsonApiCommonSyntaxWithCache(input, pathSpec), emptyBehavior, defaultValueOnEmpty, errorBehavior, - defaultValueOnError); + defaultValueOnError, + returningType, + precision, + scale, + roundingMode); } public @Nullable Object jsonValue(JsonValueContext input, @@ -257,20 +267,32 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonValueEmptyOrErrorBehavior emptyBehavior, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, - Object defaultValueOnError) { + Object defaultValueOnError, + SqlTypeName returningType, + int precision, + int scale, + RoundingMode roundingMode) { return jsonValue( jsonApiCommonSyntax(input, pathSpec), emptyBehavior, defaultValueOnEmpty, errorBehavior, - defaultValueOnError); + defaultValueOnError, + returningType, + precision, + scale, + roundingMode); } public @Nullable Object jsonValue(JsonPathContext context, SqlJsonValueEmptyOrErrorBehavior emptyBehavior, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, - Object defaultValueOnError) { + Object defaultValueOnError, + SqlTypeName returningType, + int precision, + int scale, + RoundingMode roundingMode) { final Exception exc; if (context.hasException()) { exc = context.exc; @@ -284,7 +306,8 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, case NULL: return null; case DEFAULT: - return defaultValueOnEmpty; + return convertDefaultValue(defaultValueOnEmpty, returningType, + precision, scale, roundingMode); default: throw RESOURCE.illegalEmptyBehaviorInJsonValueFunc( emptyBehavior.toString()).ex(); @@ -295,7 +318,13 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, RESOURCE.scalarValueRequiredInStrictModeOfJsonValueFunc( value.toString()).ex(); } else { - return value; + try { + return SqlFunctions.cast(value, returningType, precision, scale, + roundingMode); + } catch (Exception e) { + // A failed conversion is an error, so ON ERROR applies. + exc = e; + } } } switch (errorBehavior) { @@ -304,7 +333,8 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, case NULL: return null; case DEFAULT: - return defaultValueOnError; + return convertDefaultValue(defaultValueOnError, returningType, + precision, scale, roundingMode); default: throw RESOURCE.illegalErrorBehaviorInJsonValueFunc( errorBehavior.toString()).ex(); @@ -317,10 +347,16 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryWrapperBehavior wrapperBehavior, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, - boolean jsonize) { + boolean jsonize, + int arrayDepth, + SqlTypeName elementType, + int precision, + int scale, + RoundingMode roundingMode) { return jsonQuery( jsonApiCommonSyntaxWithCache(input, pathSpec), - wrapperBehavior, emptyBehavior, errorBehavior, jsonize); + wrapperBehavior, emptyBehavior, errorBehavior, jsonize, arrayDepth, + elementType, precision, scale, roundingMode); } public @Nullable Object jsonQuery(JsonValueContext input, @@ -328,10 +364,16 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryWrapperBehavior wrapperBehavior, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, - boolean jsonize) { + boolean jsonize, + int arrayDepth, + SqlTypeName elementType, + int precision, + int scale, + RoundingMode roundingMode) { return jsonQuery( jsonApiCommonSyntax(input, pathSpec), - wrapperBehavior, emptyBehavior, errorBehavior, jsonize); + wrapperBehavior, emptyBehavior, errorBehavior, jsonize, arrayDepth, + elementType, precision, scale, roundingMode); } public @Nullable Object jsonQuery( @@ -339,7 +381,12 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryWrapperBehavior wrapperBehavior, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, - boolean jsonize) { + boolean jsonize, + int arrayDepth, + SqlTypeName elementType, + int precision, + int scale, + RoundingMode roundingMode) { final Exception exc; if (context.hasException()) { exc = context.exc; @@ -394,7 +441,13 @@ && isScalarObject(value)) { exc = e; } } else { - return value; + try { + return SqlFunctions.castArray(value, elementType, precision, + scale, roundingMode, arrayDepth); + } catch (Exception e) { + // A failed conversion is an error, so ON ERROR applies. + exc = e; + } } } } @@ -413,6 +466,21 @@ && isScalarObject(value)) { } } + /** Converts the value of a {@code DEFAULT} clause to the type of the + * {@code RETURNING} clause. + * + *

The value is a SQL value of the type it was written as, not a + * value read from a JSON document, so the only conversion it can need is + * between numeric types, as in + * {@code RETURNING DOUBLE DEFAULT 1 ON EMPTY}. */ + private static @Nullable Object convertDefaultValue(@Nullable Object value, + SqlTypeName typeName, int precision, int scale, + RoundingMode roundingMode) { + return SqlTypeName.NUMERIC_TYPES.contains(typeName) + ? SqlFunctions.cast(value, typeName, precision, scale, roundingMode) + : value; + } + private static Object jsonQueryEmptyArray(boolean jsonize) { return jsonize ? "[]" : Collections.emptyList(); } diff --git a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java index 4e77144243ed..5c5c1e72973d 100644 --- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java @@ -44,6 +44,7 @@ import org.apache.calcite.sql.SqlUtil; import org.apache.calcite.sql.fun.SqlLibraryOperators; import org.apache.calcite.sql.parser.SqlParserUtil; +import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.util.NumberUtil; import org.apache.calcite.util.TimeWithTimeZoneString; import org.apache.calcite.util.TimestampWithTimeZoneString; @@ -5636,6 +5637,194 @@ public static BigDecimal toBigDecimal(Object o) { : toBigDecimal(o.toString()); } + /** Converts a value to the SQL type {@code typeName}, as {@code CAST} + * does, when the type of the value is not known until run time. + * + *

Throws if the value cannot be converted, and for a target type that + * this method does not handle, so that a caller such as + * {@code JSON_VALUE} can apply its {@code ON ERROR} clause. + * + * @param value Value to convert + * @param typeName Type to convert it to; {@link SqlTypeName#ANY} + * returns the value unchanged + * @param precision Precision of the target type, or negative + * @param scale Scale of the target type, or negative + * @param roundingMode Rounding mode of the type system + */ + static @Nullable Object cast(@Nullable Object value, + SqlTypeName typeName, int precision, int scale, + RoundingMode roundingMode) { + if (value == null || typeName == SqlTypeName.ANY) { + return value; + } + switch (typeName) { + case BOOLEAN: + return toBoolean(value); + case TINYINT: + return castToExact(value, Primitive.BYTE, roundingMode); + case SMALLINT: + return castToExact(value, Primitive.SHORT, roundingMode); + case INTEGER: + return castToExact(value, Primitive.INT, roundingMode); + case BIGINT: + return castToExact(value, Primitive.LONG, roundingMode); + case REAL: + return toFloat(value); + case FLOAT: + case DOUBLE: + return toDouble(value); + case DECIMAL: + return castToDecimal(value, precision, scale, roundingMode); + case CHAR: + return precision < 0 ? value.toString() + : truncateOrPad(value.toString(), precision); + case VARCHAR: + return precision < 0 ? value.toString() + : truncate(value.toString(), precision); + case DATE: + return DateTimeUtils.dateStringToUnixDate(charValue(value, typeName)); + case TIME: + return (int) truncateFraction( + DateTimeUtils.timeStringToUnixDate(charValue(value, typeName)), + precision); + case TIME_WITH_LOCAL_TIME_ZONE: + return (int) truncateFraction( + castNonNull(toTimeWithLocalTimeZone(charValue(value, typeName))), + precision); + case TIMESTAMP: + return truncateFraction( + DateTimeUtils.timestampStringToUnixDate(charValue(value, typeName)), + precision); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return truncateFraction( + castNonNull( + toTimestampWithLocalTimeZone(charValue(value, typeName))), + precision); + default: + return cannotConvert(value, typeName); + } + } + + /** Converts a value to an array, {@code depth} levels deep, whose + * innermost elements have the SQL type {@code elementType}, as a + * {@code CAST} to that type does, when the type of the value is not known + * until run time. + * + *

The declared type drives the conversion: the value must be an array + * at each of the {@code depth} levels, and what lies below them must + * convert to {@code elementType}. A value of a different shape, such as a + * flat array where an array of arrays is wanted, is an error. + * + *

Throws if the value does not have that shape, so that a caller such + * as {@code JSON_QUERY} can apply its {@code ON ERROR} clause. + * {@link SqlTypeName#ANY} returns the value unchanged. + * + * @see #cast(Object, SqlTypeName, int, int, RoundingMode) + */ + static @Nullable Object castArray(@Nullable Object value, + SqlTypeName elementType, int precision, int scale, + RoundingMode roundingMode, int depth) { + if (value == null || elementType == SqlTypeName.ANY) { + return value; + } + if (depth == 0) { + return cast(value, elementType, precision, scale, roundingMode); + } + if (!(value instanceof Collection)) { + return cannotConvert(value, SqlTypeName.ARRAY); + } + final Collection collection = (Collection) value; + final List<@Nullable Object> list = new ArrayList<>(collection.size()); + for (Object element : collection) { + list.add( + castArray(element, elementType, precision, scale, roundingMode, + depth - 1)); + } + return list; + } + + /** Converts a value to an exact numeric type, rounding as the type system + * requires and throwing {@link ArithmeticException} if it is out of range, + * as {@code CAST} does. + * + *

Converts a number to {@link BigDecimal} first: + * {@link Primitive#integerCast} does not accept every {@link Number} a + * semi-structured value may hold, such as {@link BigInteger}. */ + private static Object castToExact(Object value, Primitive primitive, + RoundingMode roundingMode) { + if (!(value instanceof Number)) { + // Take the same path as a CAST from a character value. + switch (primitive) { + case BYTE: + return toByte(value); + case SHORT: + return toShort(value); + case INT: + return toInt(value); + default: + return toLong(value); + } + } + return requireNonNull( + Primitive.integerCast(primitive, toBigDecimal((Number) value), + roundingMode), "integerCast"); + } + + /** Converts a value to {@code DECIMAL(precision, scale)}. */ + private static @Nullable Object castToDecimal(Object value, int precision, + int scale, RoundingMode roundingMode) { + if (precision < 0 || scale < 0) { + // The type gives no precision and scale to enforce. + return toBigDecimal(value); + } + if (value instanceof BigDecimal) { + return Primitive.decimalDecimalCast((BigDecimal) value, precision, scale, + roundingMode); + } + if (value instanceof BigInteger) { + return Primitive.decimalDecimalCast(new BigDecimal((BigInteger) value), + precision, scale, roundingMode); + } + if (value instanceof Float || value instanceof Double) { + return Primitive.fpDecimalCast((Number) value, precision, scale, + roundingMode); + } + if (value instanceof Number) { + return Primitive.integerDecimalCast((Number) value, precision, scale, + roundingMode); + } + return Primitive.charToDecimalCast(value.toString(), precision, scale, + roundingMode); + } + + /** Truncates a datetime value, held as a number of milliseconds, to + * {@code precision} fractional digits of a second. + * + * @see org.apache.calcite.util.TimestampString#round(int) */ + private static long truncateFraction(long millis, int precision) { + if (precision < 0 || precision >= 3) { + return millis; + } + long unit = 1; + for (int i = precision; i < 3; i++) { + unit *= 10; + } + return truncate(millis, unit); + } + + /** Returns {@code value} as a character value, throwing if it is not one; + * the datetime types convert only from a character value. */ + private static String charValue(Object value, SqlTypeName typeName) { + if (value instanceof String) { + return (String) value; + } + return (String) cannotConvert(value, typeName); + } + + private static Object cannotConvert(Object o, SqlTypeName typeName) { + throw RESOURCE.cannotConvert(String.valueOf(o), typeName.getName()).ex(); + } + /** * Converts a SQL DATE value from the internal representation type * (number of days since January 1st, 1970) to the Java type diff --git a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java index a8c006d61e7a..c7abbd7212b5 100644 --- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java +++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java @@ -126,6 +126,7 @@ import org.apache.calcite.sql.SqlJsonQueryEmptyOrErrorBehavior; import org.apache.calcite.sql.SqlJsonQueryWrapperBehavior; import org.apache.calcite.sql.SqlJsonValueEmptyOrErrorBehavior; +import org.apache.calcite.sql.type.SqlTypeName; import com.google.common.collect.ImmutableMap; @@ -521,12 +522,14 @@ public enum BuiltInMethod { JSON_VALUE(JsonFunctions.StatefulFunction.class, "jsonValue", String.class, String.class, SqlJsonValueEmptyOrErrorBehavior.class, Object.class, - SqlJsonValueEmptyOrErrorBehavior.class, Object.class), + SqlJsonValueEmptyOrErrorBehavior.class, Object.class, + SqlTypeName.class, int.class, int.class, RoundingMode.class), JSON_QUERY(JsonFunctions.StatefulFunction.class, "jsonQuery", String.class, String.class, SqlJsonQueryWrapperBehavior.class, SqlJsonQueryEmptyOrErrorBehavior.class, SqlJsonQueryEmptyOrErrorBehavior.class, - boolean.class), + boolean.class, int.class, SqlTypeName.class, int.class, int.class, + RoundingMode.class), JSON_OBJECT(JsonFunctions.class, "jsonObject", SqlJsonConstructorNullClause.class), JSON_TYPE(JsonFunctions.class, "jsonType", String.class), diff --git a/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java b/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java new file mode 100644 index 000000000000..597eeb0ebbbb --- /dev/null +++ b/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.runtime; + +import org.apache.calcite.sql.type.SqlTypeName; + +import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasToString; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests {@link SqlFunctions#cast} and {@link SqlFunctions#castArray}, the + * conversions used when the type of a value is not known until run time. + * + *

Test case for + * [CALCITE-7801] + * JSON_VALUE(..., RETURNING DOUBLE) throws ClassCastException when the JSON + * number is an integer. + */ +class SqlFunctionsCastTest { + + @Test void testCastToScalarType() { + // ANY, and a null value, are returned unchanged. + assertThat(cast("x", SqlTypeName.ANY), is("x")); + assertThat(cast(null, SqlTypeName.INTEGER), nullValue()); + + // Exact numerics round towards zero and are range-checked. + assertThat(cast(1, SqlTypeName.TINYINT), is((byte) 1)); + assertThat(cast(1, SqlTypeName.SMALLINT), is((short) 1)); + assertThat(cast(1, SqlTypeName.BIGINT), is(1L)); + assertThat(cast(1.7d, SqlTypeName.BIGINT), is(1L)); + assertThat(cast(-1.7d, SqlTypeName.SMALLINT), is((short) -1)); + assertThat(cast(new BigInteger("42"), SqlTypeName.BIGINT), is(42L)); + assertThrows(ArithmeticException.class, + () -> cast(100000, SqlTypeName.TINYINT)); + assertThrows(ArithmeticException.class, + () -> cast(100000, SqlTypeName.SMALLINT)); + + // Approximate numerics. + assertThat(cast(1, SqlTypeName.REAL), is(1.0f)); + assertThat(cast("1.5", SqlTypeName.REAL), is(1.5f)); + assertThat(cast(1, SqlTypeName.FLOAT), is(1.0d)); + assertThat(cast(1, SqlTypeName.DOUBLE), is(1.0d)); + + // A character value takes the same path as a CAST from one. + assertThat(cast("100", SqlTypeName.BIGINT), is(100L)); + assertThat(cast("true", SqlTypeName.BOOLEAN), is(true)); + assertThrows(NumberFormatException.class, + () -> cast("abc", SqlTypeName.BIGINT)); + + // A target type that is not handled is an error, so that a caller can + // apply its own error clause rather than the failure escaping. + assertThrows(CalciteException.class, + () -> cast("0102", SqlTypeName.VARBINARY)); + assertThrows(CalciteException.class, + () -> cast(1, SqlTypeName.ARRAY)); + } + + @Test void testCastAppliesPrecisionAndScale() { + assertThat(cast(100, SqlTypeName.DECIMAL, 5, 2), hasToString("100.00")); + assertThat(cast("abcdef", SqlTypeName.VARCHAR, 3, -1), is("abc")); + assertThat(cast("ab", SqlTypeName.CHAR, 4, -1), is("ab ")); + + // Fractional seconds beyond the precision of the type are truncated. + // The value is a number of milliseconds, so the truncation is visible + // here in a way that it is not through a result set. + assertThat(cast("10:20:30.987", SqlTypeName.TIME, 3, -1), is(37230987)); + assertThat(cast("10:20:30.987", SqlTypeName.TIME, 0, -1), is(37230000)); + assertThat(cast("2020-01-01 10:20:30.987", SqlTypeName.TIMESTAMP, 3, -1), + is(1577874030987L)); + assertThat(cast("2020-01-01 10:20:30.987", SqlTypeName.TIMESTAMP, 0, -1), + is(1577874030000L)); + + // Only a character value converts to a datetime. + assertThrows(CalciteException.class, + () -> cast(20200101, SqlTypeName.DATE, -1, -1)); + } + + @Test void testCastArray() { + assertThat(castArray(Arrays.asList(1, 2), SqlTypeName.DOUBLE, 1), + is(Arrays.asList(1.0d, 2.0d))); + + // A nested array is converted at every level. + assertThat( + castArray( + Arrays.asList(Arrays.asList(1, 2), Collections.singletonList(3)), + SqlTypeName.DOUBLE, 2), + is( + Arrays.asList(Arrays.asList(1.0d, 2.0d), + Collections.singletonList(3.0d)))); + + // Elements of any type the cast handles, here as the number of days + // that a DATE is held as. Asserting the value rather than how it is + // rendered keeps this independent of the default time zone. + assertThat( + castArray(Arrays.asList("2020-01-01", "2020-01-02"), SqlTypeName.DATE, + 1), + is(Arrays.asList(18262, 18263))); + + // ANY, and a null value, are returned unchanged. + assertThat(castArray(Arrays.asList(1, 2), SqlTypeName.ANY, 1), + is(Arrays.asList(1, 2))); + assertThat(castArray(null, SqlTypeName.INTEGER, 1), nullValue()); + + // A value that is not an array cannot be converted to one. + assertThrows(CalciteException.class, + () -> + castArray(Collections.singletonMap("x", 1), SqlTypeName.INTEGER, + 1)); + } + + /** Tests that the declared type, not the shape of the value, drives the + * conversion: a value of the wrong shape is an error rather than an array + * that does not match the type it is declared to have. */ + @Test void testCastArrayChecksShapeAgainstType() { + // Declared one deep, but an element is itself an array. + assertThrows(CalciteException.class, + () -> + castArray(Arrays.asList(1, Arrays.asList(2, 3)), + SqlTypeName.INTEGER, 1)); + + // Declared two deep, but the value is flat. + assertThrows(CalciteException.class, + () -> castArray(Arrays.asList(1, 2), SqlTypeName.INTEGER, 2)); + } + + private static @Nullable Object cast(@Nullable Object value, + SqlTypeName typeName) { + return cast(value, typeName, -1, -1); + } + + private static @Nullable Object cast(@Nullable Object value, + SqlTypeName typeName, int precision, int scale) { + return SqlFunctions.cast(value, typeName, precision, scale, + RoundingMode.DOWN); + } + + private static @Nullable Object castArray(@Nullable Object value, + SqlTypeName elementType, int depth) { + return SqlFunctions.castArray(value, elementType, -1, -1, + RoundingMode.DOWN, depth); + } +} diff --git a/core/src/test/java/org/apache/calcite/test/JdbcTest.java b/core/src/test/java/org/apache/calcite/test/JdbcTest.java index f0b675960eb2..06826f82ed85 100644 --- a/core/src/test/java/org/apache/calcite/test/JdbcTest.java +++ b/core/src/test/java/org/apache/calcite/test/JdbcTest.java @@ -9228,18 +9228,39 @@ void checkCalciteSchemaGetSubSchemaMap(boolean cache) { .returns("C1=[1,2]; C2=[1, 2]; C3=[]; C4=[[1, 2]]\n"); } + /** Tests that a value that cannot be converted to the type in the + * {@code RETURNING} clause is governed by the {@code ON ERROR} clause. + * + *

Test case for + * [CALCITE-7801] + * JSON_VALUE(..., RETURNING DOUBLE) throws ClassCastException when the JSON + * number is an integer. */ @Test void testJsonValueError() { + // NULL ON ERROR is the default. + CalciteAssert.that() + .query("SELECT JSON_VALUE(v, 'lax $.a' RETURNING INTEGER) AS c1\n" + + "FROM (VALUES ('{\"a\": \"abc\"}')) AS t(v)\n" + + "LIMIT 10") + .returns("C1=null\n"); + + CalciteAssert.that() + .query("SELECT JSON_VALUE(v, 'lax $.a' RETURNING INTEGER" + + " DEFAULT 0 ON ERROR) AS c1\n" + + "FROM (VALUES ('{\"a\": \"abc\"}')) AS t(v)\n" + + "LIMIT 10") + .returns("C1=0\n"); + java.sql.SQLException t = assertThrows( java.sql.SQLException.class, () -> CalciteAssert.that() - .query("SELECT JSON_VALUE(v, 'lax $.a' RETURNING INTEGER) AS c1\n" + .query("SELECT JSON_VALUE(v, 'lax $.a' RETURNING INTEGER" + + " ERROR ON ERROR) AS c1\n" + "FROM (VALUES ('{\"a\": \"abc\"}')) AS t(v)\n" + "LIMIT 10") .returns("")); - assertThat( - t.getMessage(), containsString("java.lang.String cannot be cast to")); + assertThat(t.getMessage(), containsString("For input string: \"abc\"")); } @Test void testJsonQueryError() { diff --git a/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java b/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java index 9ebc696f0275..96ec16d8392d 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java @@ -24,6 +24,7 @@ import org.apache.calcite.sql.SqlJsonQueryEmptyOrErrorBehavior; import org.apache.calcite.sql.SqlJsonQueryWrapperBehavior; import org.apache.calcite.sql.SqlJsonValueEmptyOrErrorBehavior; +import org.apache.calcite.sql.type.SqlTypeName; import org.apache.calcite.util.BuiltInMethod; import com.google.common.primitives.Longs; @@ -35,6 +36,7 @@ import org.hamcrest.Matcher; import org.junit.jupiter.api.Test; +import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -710,7 +712,8 @@ private void assertJsonValueAny(JsonFunctions.JsonPathContext context, invocationDesc(BuiltInMethod.JSON_VALUE, context, emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError), f.jsonValue(context, emptyBehavior, defaultValueOnEmpty, - errorBehavior, defaultValueOnError), + errorBehavior, defaultValueOnError, SqlTypeName.ANY, + -1, -1, RoundingMode.DOWN), matcher); } @@ -726,7 +729,8 @@ private void assertJsonValueAnyFailed(JsonFunctions.JsonPathContext input, invocationDesc(BuiltInMethod.JSON_VALUE, input, emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError), () -> f.jsonValue(input, emptyBehavior, - defaultValueOnEmpty, errorBehavior, defaultValueOnError), + defaultValueOnEmpty, errorBehavior, defaultValueOnError, + SqlTypeName.ANY, -1, -1, RoundingMode.DOWN), matcher); } @@ -750,7 +754,7 @@ private void assertJsonQuery(JsonFunctions.JsonPathContext input, invocationDesc(BuiltInMethod.JSON_QUERY, input, wrapperBehavior, emptyBehavior, errorBehavior), f.jsonQuery(input, wrapperBehavior, emptyBehavior, - errorBehavior, jsonize), + errorBehavior, jsonize, 0, SqlTypeName.ANY, -1, -1, RoundingMode.DOWN), matcher); } @@ -765,7 +769,8 @@ private void assertJsonQueryFailed(JsonFunctions.JsonPathContext input, invocationDesc(BuiltInMethod.JSON_QUERY, input, wrapperBehavior, emptyBehavior, errorBehavior), () -> f.jsonQuery(input, wrapperBehavior, emptyBehavior, - errorBehavior, true), + errorBehavior, true, 0, SqlTypeName.ANY, -1, -1, + RoundingMode.DOWN), matcher); } diff --git a/site/_docs/reference.md b/site/_docs/reference.md index 54e47d58c05a..0c0cebd6f85c 100644 --- a/site/_docs/reference.md +++ b/site/_docs/reference.md @@ -2878,13 +2878,24 @@ In the following: |:---------------------- |:----------- | JSON_EXISTS(jsonValue, path [ { TRUE | FALSE | UNKNOWN | ERROR } ON ERROR ] ) | Whether a *jsonValue* satisfies a search criterion described using JSON path expression *path* | JSON_VALUE(jsonValue, path [ RETURNING type ] [ { ERROR | NULL | DEFAULT expr } ON EMPTY ] [ { ERROR | NULL | DEFAULT expr } ON ERROR ] ) | Extract an SQL scalar from a *jsonValue* using JSON path expression *path* -| JSON_QUERY(jsonValue, path [ { WITHOUT [ ARRAY ] | WITH [ CONDITIONAL | UNCONDITIONAL ] [ ARRAY ] } WRAPPER ] [ { ERROR | NULL | EMPTY ARRAY | EMPTY OBJECT } ON EMPTY ] [ { ERROR | NULL | EMPTY ARRAY | EMPTY OBJECT } ON ERROR ] ) | Extract a JSON object or JSON array from *jsonValue* using the *path* JSON path expression +| JSON_QUERY(jsonValue, path [ RETURNING type ] [ { WITHOUT [ ARRAY ] | WITH [ CONDITIONAL | UNCONDITIONAL ] [ ARRAY ] } WRAPPER ] [ { ERROR | NULL | EMPTY ARRAY | EMPTY OBJECT } ON EMPTY ] [ { ERROR | NULL | EMPTY ARRAY | EMPTY OBJECT } ON ERROR ] ) | Extract a JSON object or JSON array from *jsonValue* using the *path* JSON path expression Note: * The `ON ERROR` and `ON EMPTY` clauses define the fallback behavior of the function when an error is thrown or a null value is about to be returned. +* The `RETURNING` clause gives the type of the value that the function + returns; the default is `VARCHAR(2000)`. The value extracted from the + document is converted to that type as if by `CAST`, and a conversion that + fails is an error, and is therefore governed by the `ON ERROR` clause. In + `JSON_QUERY`, a `RETURNING` type that is an `ARRAY` converts each element. + A datetime is parsed from a JSON string, as `CAST` parses a character + value; a JSON number is not a datetime, so converting one is an error. + The precision and scale of the type are applied: a `DECIMAL(p, s)` is + scaled, a `CHAR(n)` or `VARCHAR(n)` is truncated or padded to *n* + characters, and fractional seconds beyond the precision of a datetime type + are truncated. * The `ARRAY WRAPPER` clause defines how to represent a JSON array result in `JSON_QUERY` function. The following examples compare the wrapper behaviors. diff --git a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java index e584e59c3792..af0ecbd66a7f 100644 --- a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java +++ b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java @@ -6975,7 +6975,12 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { "100", "VARCHAR(2000)"); f.checkScalar("json_value('{\"foo\":100}', 'strict $.foo' returning integer)", 100, "INTEGER"); - f.checkFails("json_value('{\"foo\":\"100\"}', 'strict $.foo' returning boolean)", + // A value that cannot be converted to the RETURNING type is an error, + // so the ON ERROR clause applies; NULL ON ERROR is the default. + f.checkScalar("json_value('{\"foo\":\"100\"}', 'strict $.foo' returning boolean)", + isNullValue(), "BOOLEAN"); + f.checkFails("json_value('{\"foo\":\"100\"}', 'strict $.foo' returning boolean " + + "error on error)", INVALID_CHAR_MESSAGE, true); f.checkScalar("json_value('{\"foo\":100}', 'lax $.foo1' returning integer " + "null on empty)", isNullValue(), "INTEGER"); @@ -7049,6 +7054,177 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { f.checkNull("json_value(cast(null as varchar), 'strict $')"); } + /** Tests the {@code RETURNING} clause of {@code JSON_VALUE}. + * + *

Test case for + * [CALCITE-7801] + * JSON_VALUE(..., RETURNING DOUBLE) throws ClassCastException when the JSON + * number is an integer. + * + *

The extracted value is converted to the {@code RETURNING} type as if + * by {@code CAST}; a failed conversion is governed by {@code ON ERROR}. */ + @Test void testJsonValueReturning() { + final SqlOperatorFixture f = fixture(); + + // Exact numeric in the document, approximate numeric in the RETURNING + // clause, and vice versa. + f.checkScalar("json_value('{\"c\":0}', '$.c' returning double)", + 0.0, "DOUBLE"); + f.checkScalar("json_value('{\"c\":100}', '$.c' returning double)", + 100.0, "DOUBLE"); + f.checkScalar("json_value('{\"c\":0.5}', '$.c' returning double)", + 0.5, "DOUBLE"); + f.checkScalar("json_value('{\"c\":0.5}', '$.c' returning integer)", + 0, "INTEGER"); + f.checkScalar("json_value('{\"c\":1.5}', '$.c' returning integer)", + 1, "INTEGER"); + f.checkScalar("json_value('{\"c\":-1.5}', '$.c' returning integer)", + -1, "INTEGER"); + + // All the numeric types are reachable from an integral JSON number. + f.checkScalar("json_value('{\"c\":1}', '$.c' returning tinyint)", + 1, "TINYINT"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning smallint)", + 1, "SMALLINT"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning integer)", + 1, "INTEGER"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning bigint)", + 1, "BIGINT"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning real)", + 1.0, "REAL"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning float)", + 1.0, "FLOAT"); + f.checkScalar("json_value('{\"c\":1}', '$.c' returning double)", + 1.0, "DOUBLE"); + + // A JSON string converts to the RETURNING type just as CAST would. + f.checkScalar("json_value('{\"c\":\"100\"}', '$.c' returning integer)", + 100, "INTEGER"); + f.checkScalar("json_value('{\"c\":\"100\"}', '$.c' returning double)", + 100.0, "DOUBLE"); + f.checkScalar("json_value('{\"c\":\"true\"}', '$.c' returning boolean)", + true, "BOOLEAN"); + f.checkScalar("json_value('{\"c\":true}', '$.c' returning boolean)", + true, "BOOLEAN"); + f.checkScalar("json_value('{\"c\":false}', '$.c' returning boolean)", + false, "BOOLEAN"); + + // Every JSON scalar converts to a character type. + f.checkScalar("json_value('{\"c\":1}', '$.c' returning varchar(10))", + "1", "VARCHAR(10)"); + f.checkScalar("json_value('{\"c\":1.5}', '$.c' returning varchar(10))", + "1.5", "VARCHAR(10)"); + f.checkScalar("json_value('{\"c\":true}', '$.c' returning varchar(10))", + "true", "VARCHAR(10)"); + + // A conversion that fails is an error, and is therefore governed by the + // ON ERROR clause. NULL ON ERROR is the default. + f.checkScalar("json_value('{\"c\":\"abc\"}', '$.c' returning integer)", + isNullValue(), "INTEGER"); + f.checkScalar("json_value('{\"c\":\"abc\"}', '$.c' returning integer " + + "null on error)", + isNullValue(), "INTEGER"); + f.checkScalar("json_value('{\"c\":\"abc\"}', '$.c' returning integer " + + "default 42 on error)", + 42, "INTEGER"); + f.checkFails("json_value('{\"c\":\"abc\"}', '$.c' returning integer " + + "error on error)", + "(?s).*For input string: \"abc\".*", true); + f.checkScalar("json_value('{\"c\":\"100\"}', '$.c' returning boolean " + + "null on error)", + isNullValue(), "BOOLEAN"); + f.checkFails("json_value('{\"c\":\"100\"}', '$.c' returning boolean " + + "error on error)", + "(?s).*Invalid character for cast: 100.*", true); + + // An out-of-range value is an error too. + f.checkScalar("json_value('{\"c\":100000}', '$.c' returning tinyint " + + "null on error)", + isNullValue(), "TINYINT"); + f.checkScalar("json_value('{\"c\":100000}', '$.c' returning tinyint " + + "default 0 on error)", + 0, "TINYINT"); + + + // A datetime is parsed from a JSON string, as CAST parses a character + // value; a JSON number is not a datetime, so converting one is an error. + f.checkScalar("json_value('{\"c\":\"2020-01-01\"}', '$.c' returning date)", + "2020-01-01", "DATE"); + f.checkScalar("json_value('{\"c\":\"10:20:30\"}', '$.c' returning time)", + "10:20:30", "TIME(0)"); + f.checkScalar("json_value('{\"c\":\"2020-01-01 10:20:30\"}', '$.c' " + + "returning timestamp)", + "2020-01-01 10:20:30", "TIMESTAMP(0)"); + f.checkScalar("json_value('{\"c\":\"2020-01-01 10:20:30 UTC\"}', '$.c' " + + "returning timestamp with local time zone)", + "2020-01-01 10:20:30", "TIMESTAMP_WITH_LOCAL_TIME_ZONE(0)"); + f.checkScalar("json_value('{\"c\":\"10:20:30 UTC\"}', '$.c' " + + "returning time with local time zone)", + "10:20:30", "TIME_WITH_LOCAL_TIME_ZONE(0)"); + f.checkScalar("json_value('{\"c\":\"nope\"}', '$.c' returning date)", + isNullValue(), "DATE"); + f.checkFails("json_value('{\"c\":\"nope\"}', '$.c' returning date " + + "error on error)", + "(?s).*Invalid DATE value, 'nope'.*", true); + f.checkScalar("json_value('{\"c\":\"nope\"}', '$.c' returning date " + + "default date '1970-01-02' on error)", + "1970-01-02", "DATE"); + f.checkScalar("json_value('{\"c\":20200101}', '$.c' returning date)", + isNullValue(), "DATE"); + f.checkFails("json_value('{\"c\":20200101}', '$.c' returning date " + + "error on error)", + "(?s).*Cannot convert 20200101 to DATE.*", true); + + // The precision and scale of the RETURNING type are applied, as by CAST. + f.checkScalar("json_value('{\"c\":100}', '$.c' returning decimal(5,2))", + "100.00", "DECIMAL(5, 2)"); + f.checkScalar("json_value('{\"c\":1.005}', '$.c' returning decimal(5,2))", + "1.00", "DECIMAL(5, 2)"); + f.checkScalar("json_value('{\"c\":\"1.005\"}', '$.c' returning decimal(5,2))", + "1.00", "DECIMAL(5, 2)"); + f.checkFails("json_value('{\"c\":123456}', '$.c' returning decimal(5,2) " + + "error on error)", + "(?s).*cannot be represented as a DECIMAL.*", true); + + // A character type longer than the value pads, and shorter truncates. + f.checkScalar("json_value('{\"c\":\"abcdef\"}', '$.c' returning varchar(3))", + "abc", "VARCHAR(3)"); + f.checkScalar("json_value('{\"c\":\"abcdef\"}', '$.c' returning char(3))", + "abc", "CHAR(3)"); + f.checkScalar("'[' || json_value('{\"c\":\"ab\"}', '$.c' returning char(9))" + + " || ']'", + "[ab ]", "CHAR(11)"); + + // Fractional seconds beyond the precision of the type are truncated. + f.checkScalar("json_value('{\"c\":\"2020-01-01 10:20:30.987\"}', '$.c' " + + "returning timestamp(0))", + "2020-01-01 10:20:30", "TIMESTAMP(0)"); + f.checkScalar("json_value('{\"c\":\"2020-01-01 10:20:30.987\"}', '$.c' " + + "returning timestamp(3))", + "2020-01-01 10:20:30.987", "TIMESTAMP(3)"); + + // A RETURNING type that cannot be converted to is an error, so the + // ON ERROR clause applies rather than the failure escaping. + f.checkScalar("json_value('{\"c\":\"0102\"}', '$.c' returning varbinary(2))", + isNullValue(), "VARBINARY(2)"); + f.checkFails("json_value('{\"c\":\"0102\"}', '$.c' returning varbinary(2) " + + "error on error)", + "(?s).*Cannot convert 0102 to VARBINARY.*", true); + + // JSON_VALUE returns a scalar, so an array RETURNING type never matches. + f.checkScalar("json_value('{\"c\":[1,2]}', '$.c' returning integer array)", + isNullValue(), "INTEGER ARRAY"); + + // The ON EMPTY clause still applies to an empty result, and its default + // value is converted to the RETURNING type as well. + f.checkScalar("json_value('{\"c\":1}', 'lax $.d' returning integer " + + "null on empty)", + isNullValue(), "INTEGER"); + f.checkScalar("json_value('{\"c\":1}', 'lax $.d' returning double " + + "default 1 on empty)", + 1.0, "DOUBLE"); + } + @Test void testJsonQuery() { final SqlOperatorFixture f = fixture(); // default pathmode the default is: strict mode @@ -7148,6 +7324,71 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { f.checkNull("json_query(cast(null as varchar), 'lax $')"); } + /** Tests the {@code RETURNING ... ARRAY} clause of {@code JSON_QUERY}. + * + *

Test case for + * [CALCITE-7801] + * JSON_VALUE(..., RETURNING DOUBLE) throws ClassCastException when the JSON + * number is an integer. Each element of the array is converted in the + * same way that {@code JSON_VALUE} converts a scalar. */ + @Test void testJsonQueryReturningArray() { + final SqlOperatorFixture f = fixture(); + + f.checkScalar("json_query('{\"c\":[0,1]}', '$.c' returning integer array)", + "[0, 1]", "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":[0,1]}', '$.c' returning double array)", + "[0.0, 1.0]", "DOUBLE ARRAY"); + f.checkScalar("json_query('{\"c\":[0,1]}', '$.c' returning bigint array)", + "[0, 1]", "BIGINT ARRAY"); + f.checkScalar("json_query('{\"c\":[0.5,1.5]}', '$.c' " + + "returning integer array)", + "[0, 1]", "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":[\"0\",\"1\"]}', '$.c' " + + "returning integer array)", + "[0, 1]", "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":[0,1]}', '$.c' returning varchar array)", + "[0, 1]", "VARCHAR ARRAY"); + + + f.checkScalar("json_query('{\"c\":[100,2]}', '$.c' " + + "returning decimal(5,2) array)", + "[100.00, 2.00]", "DECIMAL(5, 2) ARRAY"); + f.checkScalar("json_query('{\"c\":[\"abcdef\"]}', '$.c' " + + "returning varchar(3) array)", + "[abc]", "VARCHAR(3) ARRAY"); + + + // A nested array is converted element by element, to the depth of the + // value. + f.checkScalar("json_query('{\"c\":[[1,2],[3]]}', '$.c' " + + "returning double array array)", + "[[1.0, 2.0], [3.0]]", "DOUBLE ARRAY ARRAY"); + + // A value that is not an array cannot be converted to one, so the + // ON ERROR clause applies. + f.checkScalar("json_query('{\"c\":{\"x\":1}}', '$.c' " + + "returning integer array)", + isNullValue(), "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":{\"x\":1}}', '$.c' " + + "returning integer array empty array on error)", + "[]", "INTEGER ARRAY"); + + // A conversion that fails is governed by the ON ERROR clause; + // NULL ON ERROR is the default. + f.checkScalar("json_query('{\"c\":[\"a\"]}', '$.c' " + + "returning integer array)", + isNullValue(), "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":[\"a\"]}', '$.c' " + + "returning integer array null on error)", + isNullValue(), "INTEGER ARRAY"); + f.checkScalar("json_query('{\"c\":[\"a\"]}', '$.c' " + + "returning integer array empty array on error)", + "[]", "INTEGER ARRAY"); + f.checkFails("json_query('{\"c\":[\"a\"]}', '$.c' " + + "returning integer array error on error)", + "(?s).*For input string: \"a\".*", true); + } + @Test void testJsonPretty() { final SqlOperatorFixture f = fixture(); f.checkString("json_pretty('{\"foo\":100}')",