From 9e4445029411b64460603ad6f62537c391bcb431 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 20 Sep 2026 09:45:22 -0500 Subject: [PATCH 1/3] [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}')", From cb903c7c175ec358d1c5ac2f11f6767e3f8bdb9d Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Tue, 22 Sep 2026 09:54:13 -0500 Subject: [PATCH 2/3] [CALCITE-7801] JSON_VALUE/JSON_QUERY: fix datetime fractional-seconds conversion and centralize CAST as CastSpec Two related changes to the runtime CAST that JSON_VALUE and JSON_QUERY use when the type of a value is not known until run time. Datetime fractional seconds: SqlFunctions.cast reduced a datetime value's fractional seconds to the target type's precision, but a generated CAST does not -- it keeps the full millisecond value and applies the declared precision only when the value is rendered. Remove the reduction so a RETURNING TIME/TIMESTAMP conversion yields the same value a CAST would. CastSpec: describe the target of a runtime cast with CastSpec, a lightweight, org.apache.calcite.rel-free object holding the target type name, precision, scale, a recursive component spec for collection types, and the rounding mode. A single object fully specifies a conversion, so cast(value, spec) replaces the loose (SqlTypeName, precision, scale, RoundingMode) arguments. cast now handles ARRAY and MULTISET itself by recursing on the component spec, so the separate castArray method is removed. The generated code that calls JSON_VALUE and JSON_QUERY constructs the CastSpec and passes it as one argument; the BuiltInMethod bindings and JsonFunctions signatures match. --- .../adapter/enumerable/RexImpTable.java | 78 +++++----- .../org/apache/calcite/runtime/CastSpec.java | 139 ++++++++++++++++++ .../apache/calcite/runtime/JsonFunctions.java | 69 +++------ .../apache/calcite/runtime/SqlFunctions.java | 113 +++++--------- .../apache/calcite/util/BuiltInMethod.java | 6 +- .../calcite/runtime/SqlFunctionsCastTest.java | 26 ++-- .../calcite/test/SqlJsonFunctionsTest.java | 14 +- 7 files changed, 260 insertions(+), 185 deletions(-) create mode 100644 core/src/main/java/org/apache/calcite/runtime/CastSpec.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 3a16d620bea8..33dde25923e5 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 @@ -46,6 +46,7 @@ import org.apache.calcite.rex.RexNode; import org.apache.calcite.rex.RexPatternFieldRef; import org.apache.calcite.rex.RexWindowExclusion; +import org.apache.calcite.runtime.CastSpec; import org.apache.calcite.runtime.FlatLists; import org.apache.calcite.runtime.PairList; import org.apache.calcite.runtime.SqlFunctions; @@ -3371,30 +3372,32 @@ private abstract static class JsonImplementor extends MethodImplementor { 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( + /** Builds an expression that constructs the {@link CastSpec} describing a + * conversion to {@code type}, recursing on the component of a collection + * type. A null or {@link SqlTypeName#ANY} type gives a spec that leaves + * the value unchanged. */ + static Expression castSpecExpression(RexToLixTranslator translator, + @Nullable RelDataType type) { + final Expression roundingMode = 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)); + translator.typeFactory.getTypeSystem().roundingMode()); + if (type == null || type.getSqlTypeName() == SqlTypeName.ANY) { + return Expressions.new_(CastSpec.class, + Expressions.constant(SqlTypeName.ANY), + Expressions.constant(-1), Expressions.constant(-1), roundingMode); + } + final SqlTypeName typeName = type.getSqlTypeName(); + if (typeName == SqlTypeName.ARRAY || typeName == SqlTypeName.MULTISET) { + return Expressions.new_(CastSpec.class, + Expressions.constant(typeName), + castSpecExpression(translator, + requireNonNull(type.getComponentType(), "componentType"))); + } + return Expressions.new_(CastSpec.class, + Expressions.constant(typeName), + Expressions.constant(type.getPrecision()), + Expressions.constant(type.getScale()), + roundingMode); } } @@ -3455,7 +3458,11 @@ private static class JsonValueImplementor extends JsonImplementor { newOperands.add(defaultValueOnEmpty); newOperands.add(errorBehavior); newOperands.add(defaultValueOnError); - return callWithReturningType(translator, newOperands, call.getType()); + newOperands.add(castSpecExpression(translator, call.getType())); + return Expressions.call( + Expressions.new_(method.getDeclaringClass()), + method, + EnumUtils.fromInternal(method.getParameterTypes(), newOperands)); } } @@ -3479,18 +3486,17 @@ private static class JsonQueryImplementor extends JsonImplementor { } newOperands.add(jsonize); - // 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); + // Convert only when the RETURNING clause gives an array type; otherwise + // (for example a character type produced by jsonize) the value is left + // unchanged. cast recurses on the component type for the nesting. + final RelDataType returningType = + call.getType().getSqlTypeName() == SqlTypeName.ARRAY + ? call.getType() : null; + newOperands.add(castSpecExpression(translator, returningType)); + return Expressions.call( + Expressions.new_(method.getDeclaringClass()), + method, + EnumUtils.fromInternal(method.getParameterTypes(), newOperands)); } } diff --git a/core/src/main/java/org/apache/calcite/runtime/CastSpec.java b/core/src/main/java/org/apache/calcite/runtime/CastSpec.java new file mode 100644 index 000000000000..8683e903a35c --- /dev/null +++ b/core/src/main/java/org/apache/calcite/runtime/CastSpec.java @@ -0,0 +1,139 @@ +/* + * 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 java.math.RoundingMode; +import java.util.Objects; + +import static java.util.Objects.requireNonNull; + +/** + * A description of the target of a runtime conversion: everything, besides + * the value itself, that {@link SqlFunctions#cast} needs to perform a + * {@code CAST} when the type of the value is not known until run time (used, + * for example, by {@code JSON_VALUE} and {@code JSON_QUERY}). + * + *

It is not a type. It packages the {@link SqlTypeName}, precision and + * scale of the target type, a recursive {@link #getComponent() component} + * spec for collection types, and the {@link #getRoundingMode() rounding mode} + * a numeric conversion uses. This is a lightweight, + * {@code org.apache.calcite.rel}-free stand-in for the information a + * {@code RelDataType} plus its type system would supply: this runtime layer + * must not depend on the planner's type system, so a caller projects what a + * conversion needs into this object rather than passing a {@code RelDataType}. + * + *

A collection type ({@code ARRAY} or {@code MULTISET}) carries its + * component spec, so a nested type such as {@code ARRAY>} is a + * {@code CastSpec} whose component is itself a collection {@code CastSpec}. A + * collection spec takes its rounding mode from that component. + * + *

A negative {@link #precision} or {@link #scale} means the target type + * does not specify one. + */ +public class CastSpec { + /** The SQL type to convert to. */ + private final SqlTypeName typeName; + + /** The precision of the target type, or negative if it has none. */ + private final int precision; + + /** The scale of the target type, or negative if it has none. */ + private final int scale; + + /** The component spec, when {@link #typeName} is a collection type, else + * null. */ + private final @Nullable CastSpec component; + + /** The rounding mode a numeric conversion uses. */ + private final RoundingMode roundingMode; + + private CastSpec(SqlTypeName typeName, int precision, int scale, + @Nullable CastSpec component, RoundingMode roundingMode) { + this.typeName = requireNonNull(typeName, "typeName"); + this.precision = precision; + this.scale = scale; + this.component = component; + this.roundingMode = requireNonNull(roundingMode, "roundingMode"); + } + + /** Creates a scalar {@code CastSpec} with an explicit precision and scale; + * pass a negative value for either that the target type does not specify. */ + public CastSpec(SqlTypeName typeName, int precision, int scale, + RoundingMode roundingMode) { + this(typeName, precision, scale, null, roundingMode); + } + + /** Creates a collection {@code CastSpec} ({@code ARRAY} or {@code MULTISET}) + * with the given component spec, from which it takes its rounding mode. */ + public CastSpec(SqlTypeName typeName, CastSpec component) { + this(typeName, -1, -1, requireNonNull(component, "component"), + component.roundingMode); + } + + public SqlTypeName getTypeName() { + return typeName; + } + + public int getPrecision() { + return precision; + } + + public int getScale() { + return scale; + } + + /** Returns the component spec of a collection type, or null if this is not + * a collection type. */ + public @Nullable CastSpec getComponent() { + return component; + } + + public RoundingMode getRoundingMode() { + return roundingMode; + } + + @Override public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CastSpec that = (CastSpec) o; + return typeName == that.typeName + && precision == that.precision + && scale == that.scale + && Objects.equals(component, that.component) + && roundingMode == that.roundingMode; + } + + @Override public int hashCode() { + return Objects.hash(typeName, precision, scale, component, roundingMode); + } + + @Override public String toString() { + if (component != null) { + return "CastSpec(" + typeName + " of " + component + ")"; + } + return "CastSpec(" + typeName + ", " + precision + ", " + scale + ", " + + roundingMode + ")"; + } +} 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 851b824d4564..43eb32ce7b42 100644 --- a/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/JsonFunctions.java @@ -44,7 +44,6 @@ 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; @@ -246,20 +245,14 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, Object defaultValueOnError, - SqlTypeName returningType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { return jsonValue( jsonApiCommonSyntaxWithCache(input, pathSpec), emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError, - returningType, - precision, - scale, - roundingMode); + spec); } public @Nullable Object jsonValue(JsonValueContext input, @@ -268,20 +261,14 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, Object defaultValueOnError, - SqlTypeName returningType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { return jsonValue( jsonApiCommonSyntax(input, pathSpec), emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError, - returningType, - precision, - scale, - roundingMode); + spec); } public @Nullable Object jsonValue(JsonPathContext context, @@ -289,10 +276,7 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, Object defaultValueOnEmpty, SqlJsonValueEmptyOrErrorBehavior errorBehavior, Object defaultValueOnError, - SqlTypeName returningType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { final Exception exc; if (context.hasException()) { exc = context.exc; @@ -306,8 +290,7 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, case NULL: return null; case DEFAULT: - return convertDefaultValue(defaultValueOnEmpty, returningType, - precision, scale, roundingMode); + return convertDefaultValue(defaultValueOnEmpty, spec); default: throw RESOURCE.illegalEmptyBehaviorInJsonValueFunc( emptyBehavior.toString()).ex(); @@ -319,8 +302,7 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, value.toString()).ex(); } else { try { - return SqlFunctions.cast(value, returningType, precision, scale, - roundingMode); + return SqlFunctions.cast(value, spec); } catch (Exception e) { // A failed conversion is an error, so ON ERROR applies. exc = e; @@ -333,8 +315,7 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, case NULL: return null; case DEFAULT: - return convertDefaultValue(defaultValueOnError, returningType, - precision, scale, roundingMode); + return convertDefaultValue(defaultValueOnError, spec); default: throw RESOURCE.illegalErrorBehaviorInJsonValueFunc( errorBehavior.toString()).ex(); @@ -348,15 +329,10 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, boolean jsonize, - int arrayDepth, - SqlTypeName elementType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { return jsonQuery( jsonApiCommonSyntaxWithCache(input, pathSpec), - wrapperBehavior, emptyBehavior, errorBehavior, jsonize, arrayDepth, - elementType, precision, scale, roundingMode); + wrapperBehavior, emptyBehavior, errorBehavior, jsonize, spec); } public @Nullable Object jsonQuery(JsonValueContext input, @@ -365,15 +341,10 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, boolean jsonize, - int arrayDepth, - SqlTypeName elementType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { return jsonQuery( jsonApiCommonSyntax(input, pathSpec), - wrapperBehavior, emptyBehavior, errorBehavior, jsonize, arrayDepth, - elementType, precision, scale, roundingMode); + wrapperBehavior, emptyBehavior, errorBehavior, jsonize, spec); } public @Nullable Object jsonQuery( @@ -382,11 +353,7 @@ public JsonPathContext jsonApiCommonSyntaxWithCache(String input, SqlJsonQueryEmptyOrErrorBehavior emptyBehavior, SqlJsonQueryEmptyOrErrorBehavior errorBehavior, boolean jsonize, - int arrayDepth, - SqlTypeName elementType, - int precision, - int scale, - RoundingMode roundingMode) { + CastSpec spec) { final Exception exc; if (context.hasException()) { exc = context.exc; @@ -442,8 +409,7 @@ && isScalarObject(value)) { } } else { try { - return SqlFunctions.castArray(value, elementType, precision, - scale, roundingMode, arrayDepth); + return SqlFunctions.cast(value, spec); } catch (Exception e) { // A failed conversion is an error, so ON ERROR applies. exc = e; @@ -474,10 +440,9 @@ && isScalarObject(value)) { * 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) + CastSpec spec) { + return SqlTypeName.NUMERIC_TYPES.contains(spec.getTypeName()) + ? SqlFunctions.cast(value, spec) : value; } 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 5c5c1e72973d..21a426ab5a65 100644 --- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java @@ -5637,27 +5637,47 @@ 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. + /** Converts a value to the target described by {@code spec}, as + * {@code CAST} does, when the type of the value is not known until run + * time. + * + *

A collection type is converted element by element, recursing on the + * {@link CastSpec#getComponent() component} spec. * *

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 + * @param value Value to convert + * @param spec Description of the conversion target; a target type of + * {@link SqlTypeName#ANY} returns the value unchanged */ - static @Nullable Object cast(@Nullable Object value, - SqlTypeName typeName, int precision, int scale, - RoundingMode roundingMode) { + static @Nullable Object cast(@Nullable Object value, CastSpec spec) { + final SqlTypeName typeName = spec.getTypeName(); if (value == null || typeName == SqlTypeName.ANY) { return value; } + final int precision = spec.getPrecision(); + final int scale = spec.getScale(); + final RoundingMode roundingMode = spec.getRoundingMode(); switch (typeName) { + case ARRAY: + case MULTISET: { + // A collection is converted element by element, as a CAST to a + // collection type does. A value of a different shape, such as a scalar + // where an array is wanted, is an error, so that a caller such as + // JSON_QUERY can apply its ON ERROR clause. + final CastSpec component = spec.getComponent(); + if (component == null || !(value instanceof Collection)) { + return cannotConvert(value, typeName); + } + final Collection collection = (Collection) value; + final List<@Nullable Object> list = new ArrayList<>(collection.size()); + for (Object element : collection) { + list.add(cast(element, component)); + } + return list; + } case BOOLEAN: return toBoolean(value); case TINYINT: @@ -5684,65 +5704,19 @@ public static BigDecimal toBigDecimal(Object o) { case DATE: return DateTimeUtils.dateStringToUnixDate(charValue(value, typeName)); case TIME: - return (int) truncateFraction( - DateTimeUtils.timeStringToUnixDate(charValue(value, typeName)), - precision); + return DateTimeUtils.timeStringToUnixDate(charValue(value, typeName)); case TIME_WITH_LOCAL_TIME_ZONE: - return (int) truncateFraction( - castNonNull(toTimeWithLocalTimeZone(charValue(value, typeName))), - precision); + return toTimeWithLocalTimeZone(charValue(value, typeName)); case TIMESTAMP: - return truncateFraction( - DateTimeUtils.timestampStringToUnixDate(charValue(value, typeName)), - precision); + return DateTimeUtils.timestampStringToUnixDate( + charValue(value, typeName)); case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return truncateFraction( - castNonNull( - toTimestampWithLocalTimeZone(charValue(value, typeName))), - precision); + return toTimestampWithLocalTimeZone(charValue(value, typeName)); 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. @@ -5797,21 +5771,6 @@ private static Object castToExact(Object value, Primitive primitive, 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) { 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 c7abbd7212b5..66e92b8cfbd6 100644 --- a/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java +++ b/core/src/main/java/org/apache/calcite/util/BuiltInMethod.java @@ -90,6 +90,7 @@ import org.apache.calcite.runtime.Automaton; import org.apache.calcite.runtime.BinarySearch; import org.apache.calcite.runtime.Bindable; +import org.apache.calcite.runtime.CastSpec; import org.apache.calcite.runtime.CompressionFunctions; import org.apache.calcite.runtime.Enumerables; import org.apache.calcite.runtime.FlatLists; @@ -523,13 +524,12 @@ public enum BuiltInMethod { String.class, String.class, SqlJsonValueEmptyOrErrorBehavior.class, Object.class, SqlJsonValueEmptyOrErrorBehavior.class, Object.class, - SqlTypeName.class, int.class, int.class, RoundingMode.class), + CastSpec.class), JSON_QUERY(JsonFunctions.StatefulFunction.class, "jsonQuery", String.class, String.class, SqlJsonQueryWrapperBehavior.class, SqlJsonQueryEmptyOrErrorBehavior.class, SqlJsonQueryEmptyOrErrorBehavior.class, - boolean.class, int.class, SqlTypeName.class, int.class, int.class, - RoundingMode.class), + boolean.class, CastSpec.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 index 597eeb0ebbbb..34b85a33dada 100644 --- a/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java +++ b/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java @@ -33,8 +33,8 @@ 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. + * Tests {@link SqlFunctions#cast}, the conversion used when the type of a + * value is not known until run time. * *

Test case for * [CALCITE-7801] @@ -85,15 +85,16 @@ class SqlFunctionsCastTest { 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. + // The value keeps its full millisecond precision, exactly as a generated + // CAST leaves it: the declared fractional-seconds precision is applied + // only when the value is rendered, not to the value itself, so it is not + // reduced here regardless of the precision requested. 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("10:20:30.987", SqlTypeName.TIME, 0, -1), is(37230987)); 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)); + is(1577874030987L)); // Only a character value converts to a datetime. assertThrows(CalciteException.class, @@ -155,13 +156,16 @@ class SqlFunctionsCastTest { private static @Nullable Object cast(@Nullable Object value, SqlTypeName typeName, int precision, int scale) { - return SqlFunctions.cast(value, typeName, precision, scale, - RoundingMode.DOWN); + return SqlFunctions.cast(value, + new CastSpec(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); + CastSpec spec = new CastSpec(elementType, -1, -1, RoundingMode.DOWN); + for (int i = 0; i < depth; i++) { + spec = new CastSpec(SqlTypeName.ARRAY, spec); + } + return SqlFunctions.cast(value, spec); } } 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 96ec16d8392d..282586d93d11 100644 --- a/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java +++ b/core/src/test/java/org/apache/calcite/test/SqlJsonFunctionsTest.java @@ -17,6 +17,7 @@ package org.apache.calcite.test; import org.apache.calcite.runtime.CalciteException; +import org.apache.calcite.runtime.CastSpec; import org.apache.calcite.runtime.JsonFunctions; import org.apache.calcite.runtime.SqlFunctions; import org.apache.calcite.sql.SqlJsonConstructorNullClause; @@ -712,8 +713,8 @@ private void assertJsonValueAny(JsonFunctions.JsonPathContext context, invocationDesc(BuiltInMethod.JSON_VALUE, context, emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError), f.jsonValue(context, emptyBehavior, defaultValueOnEmpty, - errorBehavior, defaultValueOnError, SqlTypeName.ANY, - -1, -1, RoundingMode.DOWN), + errorBehavior, defaultValueOnError, + new CastSpec(SqlTypeName.ANY, -1, -1, RoundingMode.DOWN)), matcher); } @@ -730,7 +731,7 @@ private void assertJsonValueAnyFailed(JsonFunctions.JsonPathContext input, defaultValueOnEmpty, errorBehavior, defaultValueOnError), () -> f.jsonValue(input, emptyBehavior, defaultValueOnEmpty, errorBehavior, defaultValueOnError, - SqlTypeName.ANY, -1, -1, RoundingMode.DOWN), + new CastSpec(SqlTypeName.ANY, -1, -1, RoundingMode.DOWN)), matcher); } @@ -754,7 +755,8 @@ private void assertJsonQuery(JsonFunctions.JsonPathContext input, invocationDesc(BuiltInMethod.JSON_QUERY, input, wrapperBehavior, emptyBehavior, errorBehavior), f.jsonQuery(input, wrapperBehavior, emptyBehavior, - errorBehavior, jsonize, 0, SqlTypeName.ANY, -1, -1, RoundingMode.DOWN), + errorBehavior, jsonize, + new CastSpec(SqlTypeName.ANY, -1, -1, RoundingMode.DOWN)), matcher); } @@ -769,8 +771,8 @@ private void assertJsonQueryFailed(JsonFunctions.JsonPathContext input, invocationDesc(BuiltInMethod.JSON_QUERY, input, wrapperBehavior, emptyBehavior, errorBehavior), () -> f.jsonQuery(input, wrapperBehavior, emptyBehavior, - errorBehavior, true, 0, SqlTypeName.ANY, -1, -1, - RoundingMode.DOWN), + errorBehavior, true, + new CastSpec(SqlTypeName.ANY, -1, -1, RoundingMode.DOWN)), matcher); } From b27ad7bd6c6f383b7ffbe07360da999206579cd5 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Tue, 22 Sep 2026 12:40:47 -0500 Subject: [PATCH 3/3] [CALCITE-7801] SqlFunctions.cast: support BINARY, VARBINARY, GEOMETRY and UUID targets Make the runtime cast used by JSON_VALUE feature-parallel with a generated CAST for the remaining scalar types a character value can convert to: BINARY and VARBINARY (encoding the value's bytes with the default charset, padding or truncating to the precision), GEOMETRY (from EWKT), and UUID. Add SqlFunctionsCastTest.testCastFromStringToEachType, which converts a character value to each scalar target type, and JSON_VALUE RETURNING cases for UUID and VARBINARY. --- .../apache/calcite/runtime/SqlFunctions.java | 16 ++++ .../calcite/runtime/SqlFunctionsCastTest.java | 75 ++++++++++++++++++- .../apache/calcite/test/SqlOperatorTest.java | 20 +++-- 3 files changed, 102 insertions(+), 9 deletions(-) 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 21a426ab5a65..83d6cdfb5cce 100644 --- a/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java +++ b/core/src/main/java/org/apache/calcite/runtime/SqlFunctions.java @@ -5712,6 +5712,22 @@ public static BigDecimal toBigDecimal(Object o) { charValue(value, typeName)); case TIMESTAMP_WITH_LOCAL_TIME_ZONE: return toTimestampWithLocalTimeZone(charValue(value, typeName)); + case BINARY: { + // A character value is encoded to bytes with the default charset, as a + // CAST does; BINARY zero-pads to the precision, VARBINARY truncates. + final ByteString b = + stringToBinary(charValue(value, typeName), Util.getDefaultCharset()); + return precision < 0 ? b : truncateOrPad(b, precision); + } + case VARBINARY: { + final ByteString b = + stringToBinary(charValue(value, typeName), Util.getDefaultCharset()); + return precision < 0 ? b : truncate(b, precision); + } + case GEOMETRY: + return SpatialTypeFunctions.ST_GeomFromEWKT(charValue(value, typeName)); + case UUID: + return UuidValue.fromString(charValue(value, typeName)); default: return cannotConvert(value, typeName); } diff --git a/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java b/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java index 34b85a33dada..ed871a1fccd9 100644 --- a/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java +++ b/core/src/test/java/org/apache/calcite/runtime/SqlFunctionsCastTest.java @@ -17,6 +17,7 @@ package org.apache.calcite.runtime; import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.UuidValue; import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.jupiter.api.Test; @@ -26,6 +27,7 @@ import java.util.Arrays; import java.util.Collections; +import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; @@ -72,10 +74,12 @@ class SqlFunctionsCastTest { 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. + // A target type that cannot be produced from the value is an error, so + // that a caller can apply its own error clause rather than the failure + // escaping: a type this method does not handle (ROW), or an array with no + // element value. assertThrows(CalciteException.class, - () -> cast("0102", SqlTypeName.VARBINARY)); + () -> cast(1, SqlTypeName.ROW)); assertThrows(CalciteException.class, () -> cast(1, SqlTypeName.ARRAY)); } @@ -101,6 +105,71 @@ class SqlFunctionsCastTest { () -> cast(20200101, SqlTypeName.DATE, -1, -1)); } + /** Tests converting a character value, as a JSON string is, to each scalar + * type that a {@code CAST} from a character value can target -- the + * conversion {@code JSON_VALUE(..., RETURNING )} applies. */ + @Test void testCastFromStringToEachType() { + assertThat(cast("true", SqlTypeName.BOOLEAN), is(true)); + assertThat(cast("false", SqlTypeName.BOOLEAN), is(false)); + + assertThat(cast("42", SqlTypeName.TINYINT), is((byte) 42)); + assertThat(cast("42", SqlTypeName.SMALLINT), is((short) 42)); + assertThat(cast("42", SqlTypeName.INTEGER), is(42)); + assertThat(cast("42", SqlTypeName.BIGINT), is(42L)); + + assertThat(cast("1.5", SqlTypeName.REAL), is(1.5f)); + assertThat(cast("1.5", SqlTypeName.FLOAT), is(1.5d)); + assertThat(cast("1.5", SqlTypeName.DOUBLE), is(1.5d)); + assertThat(cast("100.5", SqlTypeName.DECIMAL, 5, 2), hasToString("100.50")); + + assertThat(cast("abc", SqlTypeName.CHAR, 5, -1), is("abc ")); + assertThat(cast("abcdef", SqlTypeName.VARCHAR, 3, -1), is("abc")); + + // Datetimes; asserting the internal value (days for DATE, milliseconds of + // the day for TIME, milliseconds since the epoch for TIMESTAMP) keeps this + // independent of the default time zone. + assertThat(cast("2020-01-01", SqlTypeName.DATE), is(18262)); + assertThat(cast("10:20:30", SqlTypeName.TIME), is(37230000)); + assertThat(cast("2020-01-01 10:20:30", SqlTypeName.TIMESTAMP), + is(1577874030000L)); + + // The local-time-zone types read a zone from the value and hold a + // normalized value, so assert only that the conversion yields the right + // kind of value. + assertThat(cast("10:20:30 UTC", SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE), + instanceOf(Integer.class)); + assertThat( + cast("2020-01-01 10:20:30 UTC", + SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE), + instanceOf(Long.class)); + + // Binary: the character value's bytes in the default charset + // (ISO-8859-1), truncated (VARBINARY) or zero-padded (BINARY) to the + // precision. + assertThat(cast("0102", SqlTypeName.VARBINARY), hasToString("30313032")); + assertThat(cast("0102", SqlTypeName.VARBINARY, 2, -1), hasToString("3031")); + assertThat(cast("0102", SqlTypeName.BINARY, 6, -1), + hasToString("303130320000")); + + // Geometry, parsed from its EWKT spelling. + assertThat(cast("POINT (1 2)", SqlTypeName.GEOMETRY), + hasToString("POINT (1 2)")); + + // UUID, with the hyphens that group its digits optional. + assertThat(cast("123e4567-e89b-12d3-a456-426655440000", SqlTypeName.UUID), + is(UuidValue.fromString("123e4567-e89b-12d3-a456-426655440000"))); + assertThat(cast("123e4567e89b12d3a456426655440000", SqlTypeName.UUID), + hasToString("123e4567-e89b-12d3-a456-426655440000")); + + // A string that is not a valid value for the target type is an error, so + // that the caller's ON ERROR clause applies rather than the failure + // escaping. + assertThrows(NumberFormatException.class, + () -> cast("abc", SqlTypeName.INTEGER)); + assertThrows(IllegalArgumentException.class, + () -> cast("not a uuid", SqlTypeName.UUID)); + } + @Test void testCastArray() { assertThat(castArray(Arrays.asList(1, 2), SqlTypeName.DOUBLE, 1), is(Arrays.asList(1.0d, 2.0d))); 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 af0ecbd66a7f..4dc723374f34 100644 --- a/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java +++ b/testkit/src/main/java/org/apache/calcite/test/SqlOperatorTest.java @@ -7175,6 +7175,17 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { + "error on error)", "(?s).*Cannot convert 20200101 to DATE.*", true); + // A UUID is parsed from a JSON string, as CAST parses a character value. + f.checkScalar("json_value('{\"c\":" + + "\"123e4567-e89b-12d3-a456-426655440000\"}', " + + "'$.c' returning uuid)", + "123e4567-e89b-12d3-a456-426655440000", "UUID"); + f.checkScalar("json_value('{\"c\":\"not a uuid\"}', '$.c' returning uuid)", + isNullValue(), "UUID"); + f.checkFails("json_value('{\"c\":\"not a uuid\"}', '$.c' returning uuid " + + "error on error)", + "(?s).*Invalid UUID string: not a uuid.*", 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)"); @@ -7203,13 +7214,10 @@ void checkRegexpExtract(SqlOperatorFixture f0, FunctionAlias functionAlias) { + "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. + // A binary RETURNING type encodes the JSON string's bytes with the + // default charset, as CAST does, truncating to the precision. 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); + "3031", "VARBINARY(2)"); // JSON_VALUE returns a scalar, so an array RETURNING type never matches. f.checkScalar("json_value('{\"c\":[1,2]}', '$.c' returning integer array)",