diff --git a/docs/content.zh/docs/sql/reference/data-types.md b/docs/content.zh/docs/sql/reference/data-types.md index 6ad9cd02c7a782..a4cc424feb40a6 100644 --- a/docs/content.zh/docs/sql/reference/data-types.md +++ b/docs/content.zh/docs/sql/reference/data-types.md @@ -1086,7 +1086,7 @@ The type can be declared using the above combinations where `p1` is the number o and `9` (both inclusive). If no `p1` is specified, it is equal to `2` by default. If no `p2` is specified, it is equal to `6` by default. -### Constructured Data Types +### Constructed Data Types #### `ARRAY` @@ -1507,7 +1507,7 @@ close to the semantics of JSON. Compared to `ROW` and `STRUCTURED` type, `VARIAN flexibility to support highly nested and evolving schema. `VARIANT` allows for deeply nested data structures, such as arrays within arrays, maps within maps, -or combinations of both.This capability makes `VARIANT` ideal for scenarios where data complexity +or combinations of both. This capability makes `VARIANT` ideal for scenarios where data complexity and nesting are significant. `VARIANT` allows schema evolution, enabling the storage of data with changing or unknown schemas @@ -1515,11 +1515,47 @@ without requiring upfront schema definition. For example, if a new field is adde can be directly incorporated into the `VARIANT` data without modifying the table schema. This is particularly useful in dynamic environments where schemas may evolve over time. +A `VARIANT` stores a single value of one of the following kinds: `NULL`, `BOOLEAN`, `TINYINT`, +`SMALLINT`, `INT`, `BIGINT`, `FLOAT`, `DOUBLE`, `DECIMAL` (up to precision 38), `STRING`, `DATE`, +`TIMESTAMP`, `TIMESTAMP_LTZ`, `BYTES`, or a nested array or object. `TIMESTAMP` and `TIMESTAMP_LTZ` +are stored with microsecond precision. + +The `PARSE_JSON` function produces only the kinds that JSON syntax can express: + +| JSON input | Stored `VARIANT` kind | +|-----------------------------------------------------|-------------------------------------------------| +| `null` | `NULL` | +| `true` / `false` | `BOOLEAN` | +| Integer within the 64-bit signed range | smallest of `TINYINT`/`SMALLINT`/`INT`/`BIGINT` | +| Integer beyond 64-bit, up to 38 significant digits | `DECIMAL` | +| Decimal in plain notation, up to precision/scale 38 | `DECIMAL` | +| Number in scientific notation, e.g. `1.5e3` | `DOUBLE` | +| Number exceeding 38 digits of precision or scale | `DOUBLE` | +| String | `STRING` | +| Array | array (elements encoded by the same rules) | +| Object | object (values encoded by the same rules) | + +Because JSON has no literal for float, date, timestamp, or binary values, `PARSE_JSON` never +produces `FLOAT`, `DATE`, `TIMESTAMP`, `TIMESTAMP_LTZ`, or `BYTES`. A `VARIANT` can still hold +those kinds when built by other producers, such as the programmatic `VariantBuilder` API or format +conversions like Avro. + +The JSON specification has no `NaN` or infinity literals, so `PARSE_JSON('NaN')`, +`PARSE_JSON('Infinity')`, and `PARSE_JSON('-Infinity')` fail. `PARSE_JSON('1e400')` fails as well +because a value outside the `DOUBLE` range cannot be stored as a finite number. In all of these +cases `TRY_PARSE_JSON` returns `NULL`. +A `VARIANT` has no dedicated kind for these values. To keep one, store it as a JSON string and cast +it back out, for example `CAST(CAST(PARSE_JSON('"Infinity"') AS STRING) AS FLOAT)`. + A primitive-valued `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. Numeric -targets are lenient: a variant holding any numeric value casts to any numeric type, so a JSON integer -such as `PARSE_JSON('42')` casts to `INT` or `BIGINT`. Other targets require the stored value to be of -the matching kind. When the value cannot be converted, `CAST` fails the job and `TRY_CAST` returns -`NULL`. Use the `JSON_STRING` function to obtain the JSON string representation of a `VARIANT`. +targets accept any wider numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A +value outside an integer or `DECIMAL` target's range fails `CAST` and returns `NULL` for +`TRY_CAST`. Other targets require the stored value to be of the matching kind, and a mismatch is +handled the same way. Casting a `VARIANT` to `STRING` extracts the scalar value, so a +stored string is returned unquoted (for example `foo`). A variant that holds an object, array, or +binary value is not castable to a string; use `JSON_STRING` for its JSON representation instead +(there a string stays quoted, for example `"foo"`). A bounded `CHAR(n)`/`VARCHAR(n)` target is length-checked strictly, with no +padding or truncation. **Declaration** diff --git a/docs/content/docs/sql/reference/data-types.md b/docs/content/docs/sql/reference/data-types.md index 45ace31e365083..2d043f08dc307c 100644 --- a/docs/content/docs/sql/reference/data-types.md +++ b/docs/content/docs/sql/reference/data-types.md @@ -1094,7 +1094,7 @@ The type can be declared using the above combinations where `p1` is the number o and `9` (both inclusive). If no `p1` is specified, it is equal to `2` by default. If no `p2` is specified, it is equal to `6` by default. -### Constructured Data Types +### Constructed Data Types #### `ARRAY` @@ -1515,7 +1515,7 @@ close to the semantics of JSON. Compared to `ROW` and `STRUCTURED` type, `VARIAN flexibility to support highly nested and evolving schema. `VARIANT` allows for deeply nested data structures, such as arrays within arrays, maps within maps, -or combinations of both.This capability makes `VARIANT` ideal for scenarios where data complexity +or combinations of both. This capability makes `VARIANT` ideal for scenarios where data complexity and nesting are significant. `VARIANT` allows schema evolution, enabling the storage of data with changing or unknown schemas @@ -1523,11 +1523,47 @@ without requiring upfront schema definition. For example, if a new field is adde can be directly incorporated into the `VARIANT` data without modifying the table schema. This is particularly useful in dynamic environments where schemas may evolve over time. -A primitive-valued `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. Numeric -targets are lenient: a variant holding any numeric value casts to any numeric type, so a JSON integer -such as `PARSE_JSON('42')` casts to `INT` or `BIGINT`. Other targets require the stored value to be of -the matching kind. When the value cannot be converted, `CAST` fails the job and `TRY_CAST` returns -`NULL`. Use the `JSON_STRING` function to obtain the JSON string representation of a `VARIANT`. +A `VARIANT` stores a single value of one of the following kinds: `NULL`, `BOOLEAN`, `TINYINT`, +`SMALLINT`, `INT`, `BIGINT`, `FLOAT`, `DOUBLE`, `DECIMAL` (up to precision 38), `STRING`, `DATE`, +`TIMESTAMP`, `TIMESTAMP_LTZ`, `BYTES`, or a nested array or object. `TIMESTAMP` and `TIMESTAMP_LTZ` +are stored with microsecond precision. + +The `PARSE_JSON` function produces only the kinds that JSON syntax can express: + +| JSON input | Stored `VARIANT` kind | +|-----------------------------------------------------|-------------------------------------------------| +| `null` | `NULL` | +| `true` / `false` | `BOOLEAN` | +| Integer within the 64-bit signed range | smallest of `TINYINT`/`SMALLINT`/`INT`/`BIGINT` | +| Integer beyond 64-bit, up to 38 significant digits | `DECIMAL` | +| Decimal in plain notation, up to precision/scale 38 | `DECIMAL` | +| Number in scientific notation, e.g. `1.5e3` | `DOUBLE` | +| Number exceeding 38 digits of precision or scale | `DOUBLE` | +| String | `STRING` | +| Array | array (elements encoded by the same rules) | +| Object | object (values encoded by the same rules) | + +Because JSON has no literal for float, date, timestamp, or binary values, `PARSE_JSON` never +produces `FLOAT`, `DATE`, `TIMESTAMP`, `TIMESTAMP_LTZ`, or `BYTES`. A `VARIANT` can still hold +those kinds when built by other producers, such as the programmatic `VariantBuilder` API or format +conversions like Avro. + +The JSON specification has no `NaN` or infinity literals, so `PARSE_JSON('NaN')`, +`PARSE_JSON('Infinity')`, and `PARSE_JSON('-Infinity')` fail. `PARSE_JSON('1e400')` fails as well +because a value outside the `DOUBLE` range cannot be stored as a finite number. In all of these +cases `TRY_PARSE_JSON` returns `NULL`. +A `VARIANT` has no dedicated kind for these values. To keep one, store it as a JSON string and cast +it back out, for example `CAST(CAST(PARSE_JSON('"Infinity"') AS STRING) AS FLOAT)`. + +A primitive-valued `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. Numeric +targets accept any wider numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A +value outside an integer or `DECIMAL` target's range fails `CAST` and returns `NULL` for +`TRY_CAST`. Other targets require the stored value to be of the matching kind, and a mismatch is +handled the same way. Casting a `VARIANT` to `STRING` extracts the scalar value, so a +stored string is returned unquoted (for example `foo`). A variant that holds an object, array, or +binary value is not castable to a string; use `JSON_STRING` for its JSON representation instead +(there a string stays quoted, for example `"foo"`). A bounded `CHAR(n)`/`VARCHAR(n)` target is length-checked strictly, with no +padding or truncation. **Declaration** diff --git a/docs/data/sql_functions.yml b/docs/data/sql_functions.yml index c6b683c70c9e3a..31d746a1aca66e 100644 --- a/docs/data/sql_functions.yml +++ b/docs/data/sql_functions.yml @@ -1234,6 +1234,8 @@ variant: parser will keep the last occurrence of all fields with the same key, otherwise when `allowDuplicateKeys` is false it will throw an error. The default value of `allowDuplicateKeys` is false. + + See the `VARIANT` data type documentation for how JSON values are mapped to variant kinds. - sql: TRY_PARSE_JSON(json_string[, allow_duplicate_keys]) description: | Try to parse a JSON string into a Variant if possible. If the JSON string is invalid, return @@ -1243,6 +1245,8 @@ variant: parser will keep the last occurrence of all fields with the same key, otherwise when `allowDuplicateKeys` is false it will throw an error. The default value of `allowDuplicateKeys` is false. + + See the `VARIANT` data type documentation for how JSON values are mapped to variant kinds. - sql: JSON_STRING(variant) description: | Generate a json string from a Variant object. diff --git a/docs/data/sql_functions_zh.yml b/docs/data/sql_functions_zh.yml index 998f46d4134870..eb88c3897836d6 100644 --- a/docs/data/sql_functions_zh.yml +++ b/docs/data/sql_functions_zh.yml @@ -1320,6 +1320,8 @@ variant: 同键的字段,否则当 allowDuplicateKeys 为 false 时,它会抛出一个错误。默认情况下, allowDuplicateKeys 的值为 false。 + See the `VARIANT` data type documentation for how JSON values are mapped to variant kinds. + - sql: TRY_PARSE_JSON(json_string[, allow_duplicate_keys]) description: | 尽可能将 JSON 字符串解析为 Variant。如果 JSON 字符串无效,则返回 NULL。如果希望抛出错误而不是返回 NULL, @@ -1329,6 +1331,8 @@ variant: 同键的字段,否则当 allowDuplicateKeys 为 false 时,它会抛出一个错误。默认情况下, allowDuplicateKeys 的值为 false。 + See the `VARIANT` data type documentation for how JSON values are mapped to variant kinds. + - sql: JSON_STRING(variant) description: | Generate a json string from a Variant object. diff --git a/flink-core/src/main/java/org/apache/flink/types/variant/Variant.java b/flink-core/src/main/java/org/apache/flink/types/variant/Variant.java index c0f15788f3f754..dd7ac96b6fc9f7 100644 --- a/flink-core/src/main/java/org/apache/flink/types/variant/Variant.java +++ b/flink-core/src/main/java/org/apache/flink/types/variant/Variant.java @@ -94,7 +94,8 @@ public interface Variant { float getFloat() throws VariantTypeException; /** - * Get the scalar value of variant as BigDecimal, if the variant type is {@link Type#DECIMAL}. + * Get the scalar value of variant as {@link BigDecimal}, if the variant type is {@link + * Type#DECIMAL}. * * @throws VariantTypeException If this variant is not a scalar value or is not {@link * Type#DECIMAL}. @@ -118,7 +119,8 @@ public interface Variant { String getString() throws VariantTypeException; /** - * Get the scalar value of variant as LocalDate, if the variant type is {@link Type#DATE}. + * Get the scalar value of variant as {@link LocalDate}, if the variant type is {@link + * Type#DATE}. * * @throws VariantTypeException If this variant is not a scalar value or is not {@link * Type#DATE}. @@ -126,8 +128,8 @@ public interface Variant { LocalDate getDate() throws VariantTypeException; /** - * Get the scalar value of variant as LocalDateTime, if the variant type is {@link - * Type#TIMESTAMP}. + * Get the scalar value of variant as {@link LocalDateTime}, if the variant type is {@link + * Type#TIMESTAMP}. The returned value has microsecond precision. * * @throws VariantTypeException If this variant is not a scalar value or is not {@link * Type#TIMESTAMP}. @@ -135,10 +137,11 @@ public interface Variant { LocalDateTime getDateTime() throws VariantTypeException; /** - * Get the scalar value of variant as Instant, if the variant type is {@link Type#TIMESTAMP}. + * Get the scalar value of variant as {@link Instant}, if the variant type is {@link + * Type#TIMESTAMP_LTZ}. The returned value has microsecond precision. * * @throws VariantTypeException If this variant is not a scalar value or is not {@link - * Type#TIMESTAMP}. + * Type#TIMESTAMP_LTZ}. */ Instant getInstant() throws VariantTypeException; diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java index 3bd66e01cd5ecc..fbd4bf188d7519 100644 --- a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java @@ -35,7 +35,6 @@ import java.util.List; import java.util.Optional; -import static org.apache.flink.table.types.logical.utils.LogicalTypeCasts.getUnsupportedCastHint; import static org.apache.flink.table.types.logical.utils.LogicalTypeCasts.supportsExplicitCast; /** @@ -70,15 +69,6 @@ public Optional> inferInputTypes( return Optional.of(argumentDataTypes); } if (!supportsExplicitCast(fromType, toType)) { - final Optional hint = getUnsupportedCastHint(fromType, toType); - if (hint.isPresent()) { - return callContext.fail( - throwOnFailure, - "Unsupported cast from '%s' to '%s'. %s", - fromType, - toType, - hint.get()); - } return callContext.fail( throwOnFailure, "Unsupported cast from '%s' to '%s'.", fromType, toType); } diff --git a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java index 58f474ec0eb70b..d22b16399c372f 100644 --- a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java +++ b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java @@ -37,7 +37,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.function.BiFunction; import java.util.function.BiPredicate; @@ -210,7 +209,7 @@ public final class LogicalTypeCasts { castTo(CHAR) .implicitFrom(CHAR) .explicitFromFamily(PREDEFINED, CONSTRUCTED) - .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP) + .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP, VARIANT) .injectiveFrom(WHEN_LENGTH_FITS, CHAR) .injectiveFrom(WHEN_MAX_CHAR_LENGTH_FITS, STRING_INJECTIVE_SOURCES) .injectiveFrom(WHEN_CHAR_LENGTH_FITS_UTF8, BINARY, VARBINARY) @@ -219,7 +218,7 @@ public final class LogicalTypeCasts { castTo(VARCHAR) .implicitFromFamily(CHARACTER_STRING) .explicitFromFamily(PREDEFINED, CONSTRUCTED) - .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP) + .explicitFrom(RAW, NULL, STRUCTURED_TYPE, BITMAP, VARIANT) .injectiveFrom(WHEN_LENGTH_FITS, CHAR, VARCHAR) .injectiveFrom(WHEN_MAX_CHAR_LENGTH_FITS, STRING_INJECTIVE_SOURCES) .injectiveFrom(WHEN_CHAR_LENGTH_FITS_UTF8, BINARY, VARBINARY) @@ -232,7 +231,7 @@ public final class LogicalTypeCasts { castTo(BINARY) .implicitFrom(BINARY) .explicitFromFamily(CHARACTER_STRING) - .explicitFrom(VARBINARY, RAW) + .explicitFrom(VARBINARY, RAW, VARIANT) .injectiveFrom(WHEN_LENGTH_FITS, BINARY) .injectiveFrom(WHEN_BINARY_LENGTH_FITS_UTF8, CHAR, VARCHAR) .build(); @@ -240,7 +239,7 @@ public final class LogicalTypeCasts { castTo(VARBINARY) .implicitFromFamily(BINARY_STRING) .explicitFromFamily(CHARACTER_STRING) - .explicitFrom(BINARY, RAW) + .explicitFrom(BINARY, RAW, VARIANT) .injectiveFrom(WHEN_LENGTH_FITS, BINARY, VARBINARY) .injectiveFrom(WHEN_BINARY_LENGTH_FITS_UTF8, CHAR, VARCHAR) .build(); @@ -252,35 +251,55 @@ public final class LogicalTypeCasts { castTo(TINYINT) .implicitFrom(TINYINT) .explicitFromFamily(NUMERIC, CHARACTER_STRING, INTERVAL) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(TINYINT) .build(); castTo(SMALLINT) .implicitFrom(TINYINT, SMALLINT) .explicitFromFamily(NUMERIC, CHARACTER_STRING, INTERVAL) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(TINYINT, SMALLINT) .build(); castTo(INTEGER) .implicitFrom(TINYINT, SMALLINT, INTEGER) .explicitFromFamily(NUMERIC, CHARACTER_STRING, INTERVAL) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(TINYINT, SMALLINT, INTEGER) .build(); castTo(BIGINT) .implicitFrom(TINYINT, SMALLINT, INTEGER, BIGINT) .explicitFromFamily(NUMERIC, CHARACTER_STRING, INTERVAL) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(TINYINT, SMALLINT, INTEGER, BIGINT) .build(); castTo(DECIMAL) .implicitFromFamily(NUMERIC) .explicitFromFamily(CHARACTER_STRING, INTERVAL) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(WHEN_PRECISION_AND_SCALE_MATCH, DECIMAL) .build(); @@ -291,14 +310,22 @@ public final class LogicalTypeCasts { castTo(FLOAT) .implicitFrom(TINYINT, SMALLINT, INTEGER, BIGINT, FLOAT, DECIMAL) .explicitFromFamily(NUMERIC, CHARACTER_STRING) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(FLOAT) .build(); castTo(DOUBLE) .implicitFromFamily(NUMERIC) .explicitFromFamily(CHARACTER_STRING) - .explicitFrom(BOOLEAN, TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) + .explicitFrom( + BOOLEAN, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + VARIANT) .injectiveFrom(DOUBLE) .build(); @@ -309,6 +336,7 @@ public final class LogicalTypeCasts { castTo(BOOLEAN) .implicitFrom(BOOLEAN) .explicitFromFamily(CHARACTER_STRING, INTEGER_NUMERIC) + .explicitFrom(VARIANT) .injectiveFrom(BOOLEAN) .build(); @@ -319,6 +347,7 @@ public final class LogicalTypeCasts { castTo(DATE) .implicitFrom(DATE, TIMESTAMP_WITHOUT_TIME_ZONE) .explicitFromFamily(TIMESTAMP, CHARACTER_STRING) + .explicitFrom(VARIANT) .injectiveFrom(DATE) .build(); @@ -331,6 +360,7 @@ public final class LogicalTypeCasts { castTo(TIMESTAMP_WITHOUT_TIME_ZONE) .implicitFrom(TIMESTAMP_WITHOUT_TIME_ZONE, TIMESTAMP_WITH_LOCAL_TIME_ZONE) .explicitFromFamily(DATETIME, CHARACTER_STRING, NUMERIC) + .explicitFrom(VARIANT) .injectiveFrom( WHEN_PRECISION_MATCHES, TIMESTAMP_WITHOUT_TIME_ZONE, @@ -346,6 +376,7 @@ public final class LogicalTypeCasts { castTo(TIMESTAMP_WITH_LOCAL_TIME_ZONE) .implicitFrom(TIMESTAMP_WITH_LOCAL_TIME_ZONE, TIMESTAMP_WITHOUT_TIME_ZONE) .explicitFromFamily(DATETIME, CHARACTER_STRING, NUMERIC) + .explicitFrom(VARIANT) .injectiveFrom( WHEN_PRECISION_MATCHES, TIMESTAMP_WITH_LOCAL_TIME_ZONE, @@ -648,9 +679,6 @@ private static boolean supportsCasting( // BITMAP can only be cast to BYTES (unbounded VARBINARY), because trimming or padding // would corrupt the serialized bitmap data. return allowExplicit && getLength(targetType) == VarBinaryType.MAX_LENGTH; - } else if (sourceRoot == VARIANT) { - // a VARIANT can only be explicitly cast to a supported scalar type - return allowExplicit && supportsVariantToScalarCast(targetType); } if (implicitCastingRules.get(targetRoot).contains(sourceRoot)) { @@ -731,45 +759,6 @@ private static boolean supportsConstructedCasting( return false; } - private static boolean supportsVariantToScalarCast(LogicalType targetType) { - switch (targetType.getTypeRoot()) { - case BOOLEAN: - case TINYINT: - case SMALLINT: - case INTEGER: - case BIGINT: - case FLOAT: - case DOUBLE: - case DECIMAL: - case BINARY: - case VARBINARY: - case DATE: - case TIMESTAMP_WITHOUT_TIME_ZONE: - case TIMESTAMP_WITH_LOCAL_TIME_ZONE: - return true; - default: - // TIME has no counterpart in the Variant type model. CHARACTER_STRING is handled by - // the display-oriented VariantToStringCastRule and is intentionally not offered as - // a - // user-facing cast here. - return false; - } - } - - /** - * Returns a hint pointing to the function that performs a conceptually related operation when - * an explicit cast is unsupported, or empty when no specific hint applies. - */ - public static Optional getUnsupportedCastHint( - LogicalType sourceType, LogicalType targetType) { - if (sourceType.is(VARIANT) && targetType.is(CHARACTER_STRING)) { - return Optional.of( - "Use the JSON_STRING function to convert a VARIANT to its JSON string " - + "representation."); - } - return Optional.empty(); - } - private static CastingRuleBuilder castTo(LogicalTypeRoot targetType) { return new CastingRuleBuilder(targetType); } diff --git a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java index 6fb3574b1f59f6..c51408edd81fcb 100644 --- a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java +++ b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java @@ -270,6 +270,7 @@ private static Stream testData() { // variant to scalar is explicit only Arguments.of(new VariantType(), new BooleanType(), false, true), Arguments.of(new VariantType(), new TinyIntType(), false, true), + Arguments.of(new VariantType(), new SmallIntType(), false, true), Arguments.of(new VariantType(), new IntType(), false, true), Arguments.of(new VariantType(), new BigIntType(), false, true), Arguments.of(new VariantType(), new DoubleType(), false, true), @@ -284,11 +285,12 @@ private static Stream testData() { new VarBinaryType(VarBinaryType.MAX_LENGTH), false, true), + Arguments.of(new VariantType(), new CharType(), false, true), + Arguments.of(new VariantType(), VarCharType.STRING_TYPE, false, true), // variant identity cast is implicit Arguments.of(new VariantType(), new VariantType(), true, true), - // TIME, character strings and constructed targets are not castable from variant + // TIME and constructed targets are not castable from variant Arguments.of(new VariantType(), new TimeType(), false, false), - Arguments.of(new VariantType(), VarCharType.STRING_TYPE, false, false), Arguments.of(new VariantType(), new ArrayType(new IntType()), false, false), Arguments.of(new VariantType(), new RowType(List.of()), false, false), Arguments.of( diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java index 8a278f41498b57..679c6368f4aad2 100644 --- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java +++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java @@ -18,23 +18,21 @@ package org.apache.flink.table.planner.functions.casting; -import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.TimestampData; import org.apache.flink.table.planner.functions.casting.CastRuleUtils.CodeWriter; +import org.apache.flink.table.runtime.functions.VariantCastUtils; import org.apache.flink.table.types.logical.DecimalType; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.flink.table.types.logical.utils.LogicalTypeChecks; import org.apache.flink.types.variant.Variant; -import java.math.BigDecimal; import java.util.Arrays; import static org.apache.flink.table.planner.codegen.CodeGenUtils.className; import static org.apache.flink.table.planner.codegen.CodeGenUtils.newName; import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.arrayLength; import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.cast; -import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.constructorCall; import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.methodCall; import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.staticCall; import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.ternaryOperator; @@ -42,9 +40,9 @@ /** * {@link LogicalTypeRoot#VARIANT} to primitive type cast rule. * - *

Numeric targets are lenient and follow regular numeric cast semantics; other targets require - * the stored value to match the target kind. On a mismatch {@code CAST} fails and {@code TRY_CAST} - * returns {@code null}. + *

Numeric targets accept any stored numeric value but are range-checked, so an out-of-range + * integer or decimal fails {@code CAST} and yields {@code null} for {@code TRY_CAST}. Other targets + * require the stored value to match the target kind, handled the same way on a mismatch. * *

{@code CHARACTER_STRING} is handled by {@link VariantToStringCastRule}; {@code TIME} has no * variant counterpart and is unsupported. @@ -166,38 +164,35 @@ protected String generateCodeBlockInternal( } /** - * Converts a numeric variant to the numeric {@code target} via the matching {@link Number} - * accessor, mirroring regular numeric cast semantics. A non-numeric variant raises {@link - * ClassCastException}, failing {@code CAST} and yielding {@code null} for {@code TRY_CAST}. + * Converts a numeric variant to the numeric {@code target}. Every target is strict: a value + * that cannot be represented exactly, whether by integer or decimal overflow or by + * floating-point precision loss, fails {@code CAST} and yields {@code null} for {@code + * TRY_CAST}. A non-numeric variant raises {@link ClassCastException}, which fails {@code CAST} + * and yields {@code null} for {@code TRY_CAST}. */ private static String numericExpression(String inputTerm, LogicalType target) { final String number = cast(className(Number.class), methodCall(inputTerm, "get")); - if (!target.is(LogicalTypeRoot.DECIMAL)) { - return methodCall(number, numberAccessor(target)); - } - final DecimalType decimalType = (DecimalType) target; - return staticCall( - DecimalData.class, - "fromBigDecimal", - constructorCall(BigDecimal.class, methodCall(number, "toString")), - decimalType.getPrecision(), - decimalType.getScale()); - } - - private static String numberAccessor(LogicalType target) { switch (target.getTypeRoot()) { case TINYINT: - return "byteValue"; + return staticCall(VariantCastUtils.class, "toByteExact", number); case SMALLINT: - return "shortValue"; + return staticCall(VariantCastUtils.class, "toShortExact", number); case INTEGER: - return "intValue"; + return staticCall(VariantCastUtils.class, "toIntExact", number); case BIGINT: - return "longValue"; + return staticCall(VariantCastUtils.class, "toLongExact", number); case FLOAT: - return "floatValue"; + return staticCall(VariantCastUtils.class, "toFloatExact", number); case DOUBLE: - return "doubleValue"; + return staticCall(VariantCastUtils.class, "toDoubleExact", number); + case DECIMAL: + final DecimalType decimalType = (DecimalType) target; + return staticCall( + VariantCastUtils.class, + "toDecimalExact", + number, + decimalType.getPrecision(), + decimalType.getScale()); default: throw new IllegalArgumentException( "Unsupported numeric target for casting from VARIANT: " + target); diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java index 29e7821bc3a281..15ba5b0a1645aa 100644 --- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java +++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java @@ -18,15 +18,25 @@ package org.apache.flink.table.planner.functions.casting; +import org.apache.flink.table.runtime.functions.VariantCastUtils; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.LogicalTypeFamily; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.apache.flink.table.types.logical.utils.LogicalTypeChecks; import org.apache.flink.types.variant.Variant; -import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.methodCall; -import static org.apache.flink.table.types.logical.VarCharType.STRING_TYPE; +import static org.apache.flink.table.planner.functions.casting.CastRuleUtils.staticCall; -/** {@link LogicalTypeRoot#VARIANT} to {@link LogicalTypeFamily#CHARACTER_STRING} cast rule. */ +/** + * {@link LogicalTypeRoot#VARIANT} to {@link LogicalTypeFamily#CHARACTER_STRING} cast rule. + * + *

Extracts the scalar value (a string stays unquoted). A variant holding an object, array, or + * binary value is not castable to a character string and fails; use {@code JSON_STRING} for its + * JSON representation. + * + *

The target {@code CHAR}/{@code VARCHAR} length is enforced strictly: a value that does not fit + * fails {@code CAST} and yields {@code null} for {@code TRY_CAST}, with no padding or truncation. + */ class VariantToStringCastRule extends AbstractCharacterFamilyTargetRule { static final VariantToStringCastRule INSTANCE = new VariantToStringCastRule(); @@ -35,16 +45,27 @@ private VariantToStringCastRule() { super( CastRulePredicate.builder() .input(LogicalTypeRoot.VARIANT) - .target(STRING_TYPE) + .target(LogicalTypeRoot.CHAR) + .target(LogicalTypeRoot.VARCHAR) .build()); } + @Override + public boolean canFail(LogicalType inputLogicalType, LogicalType targetLogicalType) { + return true; + } + @Override public String generateStringExpression( CodeGeneratorCastRule.Context context, String inputTerm, LogicalType inputLogicalType, LogicalType targetLogicalType) { - return methodCall(inputTerm, "toString"); + return staticCall( + VariantCastUtils.class, + "toStringValue", + inputTerm, + LogicalTypeChecks.getLength(targetLogicalType), + targetLogicalType.is(LogicalTypeRoot.CHAR)); } } diff --git a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java index 6ad3ab7cb1192c..88b2053b9a5dce 100644 --- a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java +++ b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java @@ -138,10 +138,9 @@ Stream getTestSetSpecs() { } private static List variantCasts() { - // A variant is produced with PARSE_JSON so that the source column stays a STRING literal; - // there is no VARIANT literal to feed a source column directly. A JSON integer is stored in - // the smallest integer type that fits (42 -> TINYINT), and numeric casts are lenient, so it - // still widens to INT. + // A variant is produced with PARSE_JSON since there is no VARIANT literal. Numeric casts + // succeed only when the value is preserved exactly, otherwise CAST fails and TRY_CAST + // returns NULL. return List.of( TestSetSpec.forExpression("Cast a VARIANT produced by PARSE_JSON to a primitive") .onFieldsWithData("unused") @@ -151,47 +150,103 @@ private static List variantCasts() { "CAST(PARSE_JSON('42') AS INT)", 42, INT().notNull()) - // Integer overflow wraps around (Java narrowing), like a regular numeric - // cast. + // An integer value outside the target range fails + // CAST and returns NULL for TRY_CAST, instead of wrapping around. + .testTableApiRuntimeError( + call("PARSE_JSON", "40000").cast(SMALLINT()), "overflowed") + .testSqlRuntimeError("CAST(PARSE_JSON('40000') AS SMALLINT)", "overflowed") .testResult( - call("PARSE_JSON", "40000").cast(SMALLINT()), - "CAST(PARSE_JSON('40000') AS SMALLINT)", - (short) -25536, - SMALLINT().notNull()) - .testResult( - call("PARSE_JSON", "128").cast(TINYINT()), - "CAST(PARSE_JSON('128') AS TINYINT)", - (byte) -128, - TINYINT().notNull()) + call("PARSE_JSON", "40000").tryCast(SMALLINT()), + "TRY_CAST(PARSE_JSON('40000') AS SMALLINT)", + null, + SMALLINT()) + .testTableApiRuntimeError( + call("PARSE_JSON", "128").cast(TINYINT()), "overflowed") .testResult( - call("PARSE_JSON", "2147483648").cast(INT()), - "CAST(PARSE_JSON('2147483648') AS INT)", - -2147483648, - INT().notNull()) + call("PARSE_JSON", "128").tryCast(TINYINT()), + "TRY_CAST(PARSE_JSON('128') AS TINYINT)", + null, + TINYINT()) + .testTableApiRuntimeError( + call("PARSE_JSON", "2147483648").cast(INT()), "overflowed") .testResult( + call("PARSE_JSON", "2147483648").tryCast(INT()), + "TRY_CAST(PARSE_JSON('2147483648') AS INT)", + null, + INT()) + .testTableApiRuntimeError( call("PARSE_JSON", "9223372036854775808").cast(BIGINT()), - "CAST(PARSE_JSON('9223372036854775808') AS BIGINT)", - -9223372036854775808L, - BIGINT().notNull()) - // An out-of-range floating point value saturates when cast to an integer, - // and a fractional value is truncated toward zero. + "overflowed") .testResult( - call("PARSE_JSON", "1e20").cast(INT()), - "CAST(PARSE_JSON('1e20') AS INT)", - 2147483647, - INT().notNull()) + call("PARSE_JSON", "9223372036854775808").tryCast(BIGINT()), + "TRY_CAST(PARSE_JSON('9223372036854775808') AS BIGINT)", + null, + BIGINT()) + // An out-of-range floating point value also overflows an integer target, + // while a fractional value in range is truncated toward zero. + .testTableApiRuntimeError( + call("PARSE_JSON", "1e20").cast(INT()), "overflowed") + .testResult( + call("PARSE_JSON", "1e20").tryCast(INT()), + "TRY_CAST(PARSE_JSON('1e20') AS INT)", + null, + INT()) .testResult( call("PARSE_JSON", "3.9").cast(INT()), "CAST(PARSE_JSON('3.9') AS INT)", 3, INT().notNull()) - // A value beyond the FLOAT range becomes infinity. + // Floating-point targets are strict. 1e20 needs more mantissa bits than a + // FLOAT can hold, so the FLOAT cast loses precision and fails (TRY_CAST + // returns NULL). + .testTableApiRuntimeError( + call("PARSE_JSON", "1e20").cast(FLOAT()), "lose precision") .testResult( - call("PARSE_JSON", "1e40").cast(FLOAT()), - "CAST(PARSE_JSON('1e40') AS FLOAT)", - Float.POSITIVE_INFINITY, - FLOAT().notNull()) - // DECIMAL overflow yields NULL instead of wrapping; TRY_CAST surfaces it. + call("PARSE_JSON", "1e20").tryCast(FLOAT()), + "TRY_CAST(PARSE_JSON('1e20') AS FLOAT)", + null, + FLOAT()) + // A value beyond the FLOAT range overflows to infinity, which also fails. + .testTableApiRuntimeError( + call("PARSE_JSON", "1e40").cast(FLOAT()), "overflowed") + .testResult( + call("PARSE_JSON", "1e40").tryCast(FLOAT()), + "TRY_CAST(PARSE_JSON('1e40') AS FLOAT)", + null, + FLOAT()) + // The same 1e20 value is exactly representable as a DOUBLE, so it succeeds. + .testResult( + call("PARSE_JSON", "1e20").cast(DOUBLE()), + "CAST(PARSE_JSON('1e20') AS DOUBLE)", + 1e20, + DOUBLE().notNull()) + .testResult( + call("PARSE_JSON", "42").cast(DOUBLE()), + "CAST(PARSE_JSON('42') AS DOUBLE)", + 42.0d, + DOUBLE().notNull()) + // A large integer that a DOUBLE cannot hold exactly loses precision, so the + // cast fails rather than silently rounding (2^53 + 1). + .testTableApiRuntimeError( + call("PARSE_JSON", "9007199254740993").cast(DOUBLE()), + "lose precision") + .testResult( + call("PARSE_JSON", "9007199254740993").tryCast(DOUBLE()), + "TRY_CAST(PARSE_JSON('9007199254740993') AS DOUBLE)", + null, + DOUBLE()) + // A decimal that is not exactly representable in binary floating point also + // fails; cast it to DECIMAL first if an approximate DOUBLE is wanted. + .testTableApiRuntimeError( + call("PARSE_JSON", "0.1").cast(DOUBLE()), "lose precision") + .testResult( + call("PARSE_JSON", "0.1").tryCast(DOUBLE()), + "TRY_CAST(PARSE_JSON('0.1') AS DOUBLE)", + null, + DOUBLE()) + // DECIMAL overflow fails CAST and returns NULL for TRY_CAST. + .testTableApiRuntimeError( + call("PARSE_JSON", "123.456").cast(DECIMAL(4, 2)), "overflowed") .testResult( call("PARSE_JSON", "123.456").tryCast(DECIMAL(4, 2)), "TRY_CAST(PARSE_JSON('123.456') AS DECIMAL(4, 2))", @@ -207,6 +262,65 @@ private static List variantCasts() { "CAST(PARSE_JSON('true') AS BOOLEAN)", true, BOOLEAN().notNull()) + // CAST returns the raw scalar value (string unquoted) + .testResult( + call("PARSE_JSON", "\"foo\"").cast(STRING()), + "CAST(PARSE_JSON('\"foo\"') AS STRING)", + "foo", + STRING().notNull()) + .testResult( + call("PARSE_JSON", "123.456").cast(STRING()), + "CAST(PARSE_JSON('123.456') AS STRING)", + "123.456", + STRING().notNull()) + .testResult( + call("PARSE_JSON", "true").cast(STRING()), + "CAST(PARSE_JSON('true') AS STRING)", + "true", + STRING().notNull()) + // Objects and arrays are not scalar strings: CAST fails and points to + // JSON_STRING, TRY_CAST returns NULL. Use JSON_STRING for the JSON form. + .testTableApiRuntimeError( + call("PARSE_JSON", "[\"a\", \"b\"]").cast(STRING()), "JSON_STRING") + .testResult( + call("PARSE_JSON", "[\"a\", \"b\"]").tryCast(STRING()), + "TRY_CAST(PARSE_JSON('[\"a\", \"b\"]') AS STRING)", + null, + STRING()) + .testTableApiRuntimeError( + call("PARSE_JSON", "{\"a\": 1}").cast(STRING()), "JSON_STRING") + .testResult( + call("PARSE_JSON", "{\"a\": 1}").tryCast(STRING()), + "TRY_CAST(PARSE_JSON('{\"a\": 1}') AS STRING)", + null, + STRING()) + // Bounded CHAR/VARCHAR is strict on length. + // VARCHAR(n) allows any length up to n; + // CHAR(n) requires the exact length. + .testResult( + call("PARSE_JSON", "\"ab\"").cast(VARCHAR(3)), + "CAST(PARSE_JSON('\"ab\"') AS VARCHAR(3))", + "ab", + VARCHAR(3).notNull()) + .testTableApiRuntimeError( + call("PARSE_JSON", "\"foobar\"").cast(VARCHAR(3)), "does not fit") + .testResult( + call("PARSE_JSON", "\"foobar\"").tryCast(VARCHAR(3)), + "TRY_CAST(PARSE_JSON('\"foobar\"') AS VARCHAR(3))", + null, + VARCHAR(3)) + .testResult( + call("PARSE_JSON", "\"abc\"").cast(CHAR(3)), + "CAST(PARSE_JSON('\"abc\"') AS CHAR(3))", + "abc", + CHAR(3).notNull()) + .testTableApiRuntimeError( + call("PARSE_JSON", "\"ab\"").cast(CHAR(5)), "does not fit") + .testResult( + call("PARSE_JSON", "\"ab\"").tryCast(CHAR(5)), + "TRY_CAST(PARSE_JSON('\"ab\"') AS CHAR(5))", + null, + CHAR(5)) // TRY_CAST of a value whose kind does not match the target returns NULL .testResult( call("PARSE_JSON", "\"foo\"").tryCast(INT()), @@ -230,12 +344,7 @@ private static List variantCasts() { call("TRY_PARSE_JSON", "42").cast(INT()), "CAST(TRY_PARSE_JSON('42') AS INT)", 42, - INT()) - // Casting a VARIANT to a string points the user to JSON_STRING. - .testTableApiValidationError( - call("PARSE_JSON", "42").cast(STRING()), - "Use the JSON_STRING function to convert a VARIANT to its JSON " - + "string representation.")); + INT())); } private static List allTypesBasic() { diff --git a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java new file mode 100644 index 00000000000000..5119d9ade42e12 --- /dev/null +++ b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java @@ -0,0 +1,175 @@ +/* + * 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.flink.table.runtime.functions; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.TableRuntimeException; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.types.variant.Variant; + +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * Runtime helpers for casting a {@code VARIANT} value to a SQL type. + * + *

Numeric targets are strict: a value that cannot be represented exactly, whether by integer or + * decimal overflow or by floating-point precision loss, raises {@link TableRuntimeException} + * instead of wrapping or rounding. Casting to a character string extracts the scalar value (a + * string stays unquoted); a variant holding an object, array, or binary value is not castable and + * raises {@link TableRuntimeException} (use {@code JSON_STRING} for its JSON representation). + */ +@Internal +public final class VariantCastUtils { + + private VariantCastUtils() {} + + public static byte toByteExact(Number value) { + return (byte) toIntegralExact(value, Byte.MIN_VALUE, Byte.MAX_VALUE, "TINYINT"); + } + + public static short toShortExact(Number value) { + return (short) toIntegralExact(value, Short.MIN_VALUE, Short.MAX_VALUE, "SMALLINT"); + } + + public static int toIntExact(Number value) { + return (int) toIntegralExact(value, Integer.MIN_VALUE, Integer.MAX_VALUE, "INTEGER"); + } + + public static long toLongExact(Number value) { + return toIntegralExact(value, Long.MIN_VALUE, Long.MAX_VALUE, "BIGINT"); + } + + public static float toFloatExact(Number value) { + final float result = value.floatValue(); + if (!Float.isFinite(result)) { + throw overflow(value, "FLOAT"); + } + if (exactValue(value).compareTo(new BigDecimal(result)) != 0) { + throw lossyCast(value, "FLOAT"); + } + return result; + } + + public static double toDoubleExact(Number value) { + final double result = value.doubleValue(); + if (!Double.isFinite(result)) { + throw overflow(value, "DOUBLE"); + } + if (exactValue(value).compareTo(new BigDecimal(result)) != 0) { + throw lossyCast(value, "DOUBLE"); + } + return result; + } + + public static DecimalData toDecimalExact(Number value, int precision, int scale) { + final DecimalData decimal = + DecimalData.fromBigDecimal(toBigDecimal(value), precision, scale); + if (decimal == null) { + throw new TableRuntimeException( + String.format( + "Casting the VARIANT value %s to DECIMAL(%d, %d) overflowed.", + value, precision, scale)); + } + return decimal; + } + + /** + * Casts a scalar {@code VARIANT} to its raw string value, enforcing {@code targetLength} + * strictly with no padding or truncation ({@code CHAR} requires an exact length, {@code + * VARCHAR} an upper bound). + */ + public static String toStringValue(Variant variant, int targetLength, boolean charTarget) { + switch (variant.getType()) { + case OBJECT: + case ARRAY: + case BYTES: + throw new TableRuntimeException( + String.format( + "Cannot cast a VARIANT %s value to a character string. Use the " + + "JSON_STRING function to obtain its JSON representation.", + variant.getType())); + default: + // Scalars are cast to their raw string value. + } + final String value = String.valueOf(variant.get()); + final int length = value.length(); + final boolean fits = charTarget ? length == targetLength : length <= targetLength; + if (!fits) { + throw new TableRuntimeException( + String.format( + "The VARIANT string value of length %d does not fit %s(%d); VARIANT " + + "string casts do not pad or truncate.", + length, charTarget ? "CHAR" : "VARCHAR", targetLength)); + } + return value; + } + + private static long toIntegralExact(Number value, long min, long max, String targetType) { + // toBigInteger truncates any fractional part toward zero, matching regular numeric casts. + final BigInteger integral = toBigDecimal(value).toBigInteger(); + if (integral.compareTo(BigInteger.valueOf(min)) < 0 + || integral.compareTo(BigInteger.valueOf(max)) > 0) { + throw overflow(value, targetType); + } + return integral.longValue(); + } + + private static BigDecimal toBigDecimal(Number value) { + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof BigInteger) { + return new BigDecimal((BigInteger) value); + } + if (value instanceof Float || value instanceof Double) { + return BigDecimal.valueOf(value.doubleValue()); + } + return BigDecimal.valueOf(value.longValue()); + } + + // The exact mathematical value of a number, used to detect precision loss in floating-point + // casts. Unlike toBigDecimal, floats and doubles use their exact binary value rather than the + // shortest round-trippable decimal, so 0.1d becomes 0.1000000000000000055..., not 0.1. + private static BigDecimal exactValue(Number value) { + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof BigInteger) { + return new BigDecimal((BigInteger) value); + } + if (value instanceof Float || value instanceof Double) { + return new BigDecimal(value.doubleValue()); + } + return BigDecimal.valueOf(value.longValue()); + } + + private static TableRuntimeException overflow(Number value, String targetType) { + return new TableRuntimeException( + String.format("Casting the VARIANT value %s to %s overflowed.", value, targetType)); + } + + private static TableRuntimeException lossyCast(Number value, String targetType) { + return new TableRuntimeException( + String.format( + "Casting the VARIANT value %s to %s would lose precision. Cast it to a type " + + "that holds the value exactly first, then narrow if needed.", + value, targetType)); + } +}