This is an automated email from the ASF dual-hosted git repository.
snuyanzin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 2c629d77610 [FLINK-37925][table] Define `CAST` and `TRY_CAST` from
`VARIANT` to scalar types
2c629d77610 is described below
commit 2c629d776107f07d76968d2518645f93714a600b
Author: Ramin Gharib <[email protected]>
AuthorDate: Mon Aug 24 15:32:32 2026 +0200
[FLINK-37925][table] Define `CAST` and `TRY_CAST` from `VARIANT` to scalar
types
---
docs/content.zh/docs/sql/reference/data-types.md | 81 ++++-
docs/content/docs/sql/reference/data-types.md | 81 ++++-
docs/data/sql_functions.yml | 4 +
docs/data/sql_functions_zh.yml | 4 +
.../org/apache/flink/types/variant/Variant.java | 15 +-
.../strategies/CastInputTypeStrategy.java | 10 -
.../types/logical/utils/LogicalTypeCasts.java | 97 +++---
.../flink/table/types/LogicalTypeCastsTest.java | 6 +-
.../casting/VariantToPrimitiveCastRule.java | 150 ++++-----
.../functions/casting/VariantToStringCastRule.java | 75 ++++-
.../planner/functions/CastFunctionITCase.java | 243 +++++++++++---
.../planner/functions/casting/CastRulesTest.java | 255 +++++++++++++-
.../table/runtime/functions/VariantCastUtils.java | 369 +++++++++++++++++++++
13 files changed, 1154 insertions(+), 236 deletions(-)
diff --git a/docs/content.zh/docs/sql/reference/data-types.md
b/docs/content.zh/docs/sql/reference/data-types.md
index 6ad9cd02c7a..62266a004eb 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,78 @@ 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 them, `PARSE_JSON` never produces `FLOAT`,
`DATE`, `TIMESTAMP`,
+`TIMESTAMP_LTZ`, or `BYTES`.
+
+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 `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. A
cast succeeds only when
+the target holds the stored value without reinterpreting it, so a value is
never wrapped or rounded
+to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`.
+
+| Stored kind | Succeeds for |
+|-----------------|-----------------------------------------------------|
+| numeric kinds | any numeric target that holds the value |
+| `BOOLEAN` | `BOOLEAN` |
+| `DATE` | `DATE` |
+| `TIMESTAMP` | `TIMESTAMP(p)` |
+| `TIMESTAMP_LTZ` | `TIMESTAMP_LTZ(p)` |
+| `BYTES` | `BINARY(n)`, `VARBINARY(n)`, and a character string |
+| any scalar | `STRING`, `CHAR(n)`, `VARCHAR(n)` |
+| `NULL` | SQL `NULL` for any nullable target |
+
+The conditions above mean:
+
+- An **integer target** needs the value in range and without a fractional
part, so
+ `PARSE_JSON('7.0')` reaches `INT` as `7` while `PARSE_JSON('7.2')` does not,
and
+ `CAST(PARSE_JSON('1000') AS TINYINT)` fails instead of wrapping.
+- A **`DECIMAL`** target has to fit the precision and the scale. Trailing
zeros may be appended, so
+ `42` reaches `DECIMAL(5, 2)` as `42.00`, but a scale that would have to
round is rejected.
+- **`FLOAT`** and **`DOUBLE`** are approximate by definition, so they take any
numeric kind and drop
+ decimal digits, rejecting only a magnitude out of range such as `1e40` to a
`FLOAT`.
+- A **length or precision** is adjusted the same way a regular cast into that
type would: a value
+ longer than the target is trimmed, fractional seconds beyond the target
precision are truncated,
+ and the fixed width types `CHAR(n)` and `BINARY(n)` pad a shorter value.
+
+To reach a type the table does not list, wrap the cast in a regular cast. Only
the inner cast is a
+`VARIANT` cast, so the outer one applies the usual rules and may round,
truncate, or overflow:
+
+```sql
+CAST(CAST(PARSE_JSON('1000') AS SMALLINT) AS TINYINT) -- returns -24
(after overflow)
+CAST(CAST(PARSE_JSON('3.9') AS DECIMAL(2, 1)) AS INT) -- returns 3
(truncated)
+```
+
+A cast to a character string renders the value exactly as a regular SQL cast
of the stored kind
+would, so a boolean becomes `TRUE`, a timestamp uses the SQL format, a
`TIMESTAMP_LTZ` is shifted into
+the session time zone, and a binary value is read as UTF-8. Only an object or
an array has no such
+rendering. Use `JSON_STRING` for the JSON representation instead, where a
string stays quoted as
+`"foo"` and an object or array is serialized. A variant that stores a JSON
`null` casts to SQL
+`NULL`.
**Declaration**
diff --git a/docs/content/docs/sql/reference/data-types.md
b/docs/content/docs/sql/reference/data-types.md
index 45ace31e365..207c83d2792 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,78 @@ 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 them, `PARSE_JSON` never produces `FLOAT`,
`DATE`, `TIMESTAMP`,
+`TIMESTAMP_LTZ`, or `BYTES`.
+
+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 `VARIANT` can be converted to a scalar type with `CAST` or `TRY_CAST`. A
cast succeeds only when
+the target holds the stored value without reinterpreting it, so a value is
never wrapped or rounded
+to make it fit. Otherwise `CAST` fails and `TRY_CAST` returns `NULL`.
+
+| Stored kind | Succeeds for |
+|-----------------|-----------------------------------------------------|
+| numeric kinds | any numeric target that holds the value |
+| `BOOLEAN` | `BOOLEAN` |
+| `DATE` | `DATE` |
+| `TIMESTAMP` | `TIMESTAMP(p)` |
+| `TIMESTAMP_LTZ` | `TIMESTAMP_LTZ(p)` |
+| `BYTES` | `BINARY(n)`, `VARBINARY(n)`, and a character string |
+| any scalar | `STRING`, `CHAR(n)`, `VARCHAR(n)` |
+| `NULL` | SQL `NULL` for any nullable target |
+
+The conditions above mean:
+
+- An **integer target** needs the value in range and without a fractional
part, so
+ `PARSE_JSON('7.0')` reaches `INT` as `7` while `PARSE_JSON('7.2')` does not,
and
+ `CAST(PARSE_JSON('1000') AS TINYINT)` fails instead of wrapping.
+- A **`DECIMAL`** target has to fit the precision and the scale. Trailing
zeros may be appended, so
+ `42` reaches `DECIMAL(5, 2)` as `42.00`, but a scale that would have to
round is rejected.
+- **`FLOAT`** and **`DOUBLE`** are approximate by definition, so they take any
numeric kind and drop
+ decimal digits, rejecting only a magnitude out of range such as `1e40` to a
`FLOAT`.
+- A **length or precision** is adjusted the same way a regular cast into that
type would: a value
+ longer than the target is trimmed, fractional seconds beyond the target
precision are truncated,
+ and the fixed width types `CHAR(n)` and `BINARY(n)` pad a shorter value.
+
+To reach a type the table does not list, wrap the cast in a regular cast. Only
the inner cast is a
+`VARIANT` cast, so the outer one applies the usual rules and may round,
truncate, or overflow:
+
+```sql
+CAST(CAST(PARSE_JSON('1000') AS SMALLINT) AS TINYINT) -- returns -24
(after overflow)
+CAST(CAST(PARSE_JSON('3.9') AS DECIMAL(2, 1)) AS INT) -- returns 3
(truncated)
+```
+
+A cast to a character string renders the value exactly as a regular SQL cast
of the stored kind
+would, so a boolean becomes `TRUE`, a timestamp uses the SQL format, a
`TIMESTAMP_LTZ` is shifted into
+the session time zone, and a binary value is read as UTF-8. Only an object or
an array has no such
+rendering. Use `JSON_STRING` for the JSON representation instead, where a
string stays quoted as
+`"foo"` and an object or array is serialized. A variant that stores a JSON
`null` casts to SQL
+`NULL`.
**Declaration**
diff --git a/docs/data/sql_functions.yml b/docs/data/sql_functions.yml
index 19a50dad2f0..992f77f6234 100644
--- a/docs/data/sql_functions.yml
+++ b/docs/data/sql_functions.yml
@@ -1322,6 +1322,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
@@ -1331,6 +1333,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 613c561d838..cec2237d5af 100644
--- a/docs/data/sql_functions_zh.yml
+++ b/docs/data/sql_functions_zh.yml
@@ -1408,6 +1408,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,
@@ -1417,6 +1419,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 c0f15788f3f..dd7ac96b6fc 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 3bd66e01cd5..fbd4bf188d7 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.Collections;
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 @@ class CastInputTypeStrategy implements InputTypeStrategy {
return Optional.of(argumentDataTypes);
}
if (!supportsExplicitCast(fromType, toType)) {
- final Optional<String> 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 58f474ec0eb..d22b16399c3 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.HashMap;
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 @@ public final class LogicalTypeCasts {
// 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 @@ public final class LogicalTypeCasts {
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<String> 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 6fb3574b1f5..c51408edd81 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 @@ class LogicalTypeCastsTest {
// 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 @@ class LogicalTypeCastsTest {
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 8a278f41498..9b42628498f 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,33 +18,30 @@
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.codegen.CodeGenUtils.primitiveTypeTermForType;
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;
+import static
org.apache.flink.table.planner.functions.casting.CastRuleUtils.strLiteral;
/**
* {@link LogicalTypeRoot#VARIANT} to primitive type cast rule.
*
- * <p>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}.
+ * <p>A cast succeeds only when the target holds the stored value without
altering it, otherwise it
+ * fails {@code CAST} and yields {@code null} for {@code TRY_CAST}. An integer
widens or narrows as
+ * long as it stays in range, a {@code DECIMAL} has to fit the precision and
scale without rounding,
+ * and a timestamp has to fit the target precision. {@code FLOAT} and {@code
DOUBLE} are approximate
+ * by definition, so they take any numeric kind and reject only a magnitude
out of range. Changing
+ * the kind itself is not implicit, so a decimal is not read as an integer and
a {@code TIMESTAMP}
+ * is not read as a {@code TIMESTAMP_LTZ}.
*
* <p>{@code CHARACTER_STRING} is handled by {@link VariantToStringCastRule};
{@code TIME} has no
* variant counterpart and is unsupported.
@@ -128,14 +125,49 @@ class VariantToPrimitiveCastRule extends
AbstractNullAwareCodeGeneratorCastRule<
case SMALLINT:
case INTEGER:
case BIGINT:
+ writer.assignStmt(
+ returnVariable,
+ cast(
+ primitiveTypeTermForType(targetLogicalType),
+ staticCall(
+ VariantCastUtils.class,
+ "toIntegral",
+ inputTerm,
+ // A long literal needs the suffix to
compile, since
+ // Long.MIN_VALUE does not fit an int
literal.
+ integralMin(targetLogicalType) + "L",
+ integralMax(targetLogicalType) + "L",
+
strLiteral(targetLogicalType.getTypeRoot().name()))));
+ break;
case FLOAT:
+ writer.assignStmt(
+ returnVariable, staticCall(VariantCastUtils.class,
"toFloat", inputTerm));
+ break;
case DOUBLE:
+ writer.assignStmt(
+ returnVariable, staticCall(VariantCastUtils.class,
"toDouble", inputTerm));
+ break;
case DECIMAL:
- writer.assignStmt(returnVariable, numericExpression(inputTerm,
targetLogicalType));
+ final DecimalType decimalType = (DecimalType)
targetLogicalType;
+ writer.assignStmt(
+ returnVariable,
+ staticCall(
+ VariantCastUtils.class,
+ "toDecimal",
+ inputTerm,
+ decimalType.getPrecision(),
+ decimalType.getScale()));
break;
case BINARY:
case VARBINARY:
- generateToBytes(context, inputTerm, returnVariable,
targetLogicalType, writer);
+ writer.assignStmt(
+ returnVariable,
+ staticCall(
+ VariantCastUtils.class,
+ "toBytes",
+ inputTerm,
+ LogicalTypeChecks.getLength(targetLogicalType),
+ targetLogicalType.is(LogicalTypeRoot.BINARY)));
break;
case DATE:
writer.assignStmt(
@@ -146,17 +178,19 @@ class VariantToPrimitiveCastRule extends
AbstractNullAwareCodeGeneratorCastRule<
writer.assignStmt(
returnVariable,
staticCall(
- TimestampData.class,
- "fromLocalDateTime",
- methodCall(inputTerm, "getDateTime")));
+ VariantCastUtils.class,
+ "toTimestamp",
+ inputTerm,
+
LogicalTypeChecks.getPrecision(targetLogicalType)));
break;
case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
writer.assignStmt(
returnVariable,
staticCall(
- TimestampData.class,
- "fromInstant",
- methodCall(inputTerm, "getInstant")));
+ VariantCastUtils.class,
+ "toTimestampLtz",
+ inputTerm,
+
LogicalTypeChecks.getPrecision(targetLogicalType)));
break;
default:
throw new IllegalArgumentException(
@@ -165,73 +199,29 @@ class VariantToPrimitiveCastRule extends
AbstractNullAwareCodeGeneratorCastRule<
return writer.toString();
}
- /**
- * 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}.
- */
- 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) {
+ private static long integralMin(LogicalType target) {
switch (target.getTypeRoot()) {
case TINYINT:
- return "byteValue";
+ return Byte.MIN_VALUE;
case SMALLINT:
- return "shortValue";
+ return Short.MIN_VALUE;
case INTEGER:
- return "intValue";
- case BIGINT:
- return "longValue";
- case FLOAT:
- return "floatValue";
- case DOUBLE:
- return "doubleValue";
+ return Integer.MIN_VALUE;
default:
- throw new IllegalArgumentException(
- "Unsupported numeric target for casting from VARIANT:
" + target);
+ return Long.MIN_VALUE;
}
}
- private static void generateToBytes(
- CodeGeneratorCastRule.Context context,
- String inputTerm,
- String returnVariable,
- LogicalType targetLogicalType,
- CodeWriter writer) {
- final int targetLength =
LogicalTypeChecks.getLength(targetLogicalType);
- // Read the bytes once to avoid decoding the variant twice.
- final String bytesTerm = newName(context.getCodeGeneratorContext(),
"variantBytes");
- writer.declStmt("byte[]", bytesTerm, methodCall(inputTerm,
"getBytes"));
- if (BinaryToBinaryCastRule.couldPad(targetLogicalType, targetLength)) {
- // BINARY(n): pad or trim to the exact target length.
- writer.assignStmt(
- returnVariable,
- ternaryOperator(
- arrayLength(bytesTerm) + " == " + targetLength,
- bytesTerm,
- staticCall(Arrays.class, "copyOf", bytesTerm,
targetLength)));
- } else if (BinaryToBinaryCastRule.couldTrim(targetLength)) {
- // VARBINARY(n): trim only when longer than the target length.
- writer.assignStmt(
- returnVariable,
- ternaryOperator(
- arrayLength(bytesTerm) + " <= " + targetLength,
- bytesTerm,
- staticCall(Arrays.class, "copyOf", bytesTerm,
targetLength)));
- } else {
- writer.assignStmt(returnVariable, bytesTerm);
+ private static long integralMax(LogicalType target) {
+ switch (target.getTypeRoot()) {
+ case TINYINT:
+ return Byte.MAX_VALUE;
+ case SMALLINT:
+ return Short.MAX_VALUE;
+ case INTEGER:
+ return Integer.MAX_VALUE;
+ default:
+ return Long.MAX_VALUE;
}
}
}
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 29e7821bc3a..eeb9fcec9f6 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,16 +18,39 @@
package org.apache.flink.table.planner.functions.casting;
+import org.apache.flink.table.data.StringData;
+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.codegen.calls.BuiltInMethods.BINARY_STRING_DATA_FROM_STRING;
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. */
-class VariantToStringCastRule extends
AbstractCharacterFamilyTargetRule<Variant> {
+/**
+ * {@link LogicalTypeRoot#VARIANT} to {@link
LogicalTypeFamily#CHARACTER_STRING} cast rule.
+ *
+ * <p>Renders the scalar value the way a regular SQL cast of the stored kind
would, so a boolean
+ * becomes {@code TRUE}, a timestamp uses the SQL format, and a {@code
TIMESTAMP_LTZ} is shifted
+ * into the session time zone and a binary value is read as UTF-8. A variant
holding an object or
+ * array has no scalar rendering and fails; use {@code JSON_STRING} for its
JSON representation.
+ *
+ * <p>A binary value that is not well-formed UTF-8 fails instead of decoding
to {@code U+FFFD}. Cast
+ * it to {@code BYTES} to inspect the raw value, or wrap that in {@code
MAKE_VALID_UTF8} to accept
+ * the lenient decode explicitly.
+ *
+ * <p>A value that does not fit a {@code CHAR(n)} or {@code VARCHAR(n)} target
is trimmed, and a
+ * {@code CHAR} target pads a shorter value to its fixed width. The length is
enforced here rather
+ * than by {@link CharVarCharTrimPadCastRule}, so that a variant storing a
JSON {@code null} still
+ * reaches SQL {@code NULL} and so that trimming counts code points rather
than UTF-16 units.
+ *
+ * <p>Printing a result is not a cast and cannot fail, so it renders every
variant as JSON rather
+ * than extracting the scalar value. A stored string therefore prints quoted.
+ */
+class VariantToStringCastRule extends
AbstractExpressionCodeGeneratorCastRule<Variant, StringData> {
static final VariantToStringCastRule INSTANCE = new
VariantToStringCastRule();
@@ -35,16 +58,56 @@ class VariantToStringCastRule extends
AbstractCharacterFamilyTargetRule<Variant>
super(
CastRulePredicate.builder()
.input(LogicalTypeRoot.VARIANT)
- .target(STRING_TYPE)
+ .target(LogicalTypeRoot.CHAR)
+ .target(LogicalTypeRoot.VARCHAR)
.build());
}
@Override
- public String generateStringExpression(
+ public boolean canFail(LogicalType inputLogicalType, LogicalType
targetLogicalType) {
+ return true;
+ }
+
+ /**
+ * Treats a variant that stores a JSON {@code null} as a {@code NULL}
input, so it casts to SQL
+ * {@code NULL} instead of the text {@code null}. Only applied for a
nullable target: a {@code
+ * NOT NULL} result cannot carry {@code NULL}, so a null-valued variant
then fails.
+ */
+ @Override
+ public CastCodeBlock generateCodeBlock(
+ CodeGeneratorCastRule.Context context,
+ String inputTerm,
+ String inputIsNullTerm,
+ LogicalType inputLogicalType,
+ LogicalType targetLogicalType) {
+ if (!targetLogicalType.isNullable()) {
+ return super.generateCodeBlock(
+ context, inputTerm, inputIsNullTerm, inputLogicalType,
targetLogicalType);
+ }
+ final String isNullTerm =
+ "(" + inputIsNullTerm + " || " + methodCall(inputTerm,
"isNull") + ")";
+ return super.generateCodeBlock(
+ context, inputTerm, isNullTerm, inputLogicalType,
targetLogicalType);
+ }
+
+ @Override
+ public String generateExpression(
CodeGeneratorCastRule.Context context,
String inputTerm,
LogicalType inputLogicalType,
LogicalType targetLogicalType) {
- return methodCall(inputTerm, "toString");
+ if (context.isPrinting()) {
+ // Every result has to be displayable, including an object or an
array, which have no
+ // scalar rendering and would fail the cast. toJson returns a
String, so it needs the
+ // wrap that toStringValue applies itself.
+ return staticCall(BINARY_STRING_DATA_FROM_STRING(),
methodCall(inputTerm, "toJson"));
+ }
+ return staticCall(
+ VariantCastUtils.class,
+ "toStringValue",
+ inputTerm,
+ context.getSessionTimeZoneTerm(),
+ 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 6ad3ab7cb11..aee2d70c1cf 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,75 +138,221 @@ public class CastFunctionITCase extends
BuiltInFunctionTestBase {
}
private static List<TestSetSpec> 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")
.andDataTypes(STRING())
+ // An integer converts to any integer target while the
value stays in
+ // range, and to FLOAT or DOUBLE which are approximate
by definition.
+ .testResult(
+ call("PARSE_JSON", "42").cast(TINYINT()),
+ "CAST(PARSE_JSON('42') AS TINYINT)",
+ (byte) 42,
+ TINYINT().notNull())
+ .testResult(
+ call("PARSE_JSON", "42").cast(SMALLINT()),
+ "CAST(PARSE_JSON('42') AS SMALLINT)",
+ (short) 42,
+ SMALLINT().notNull())
.testResult(
call("PARSE_JSON", "42").cast(INT()),
"CAST(PARSE_JSON('42') AS INT)",
42,
INT().notNull())
- // Integer overflow wraps around (Java narrowing),
like a regular numeric
- // cast.
.testResult(
- call("PARSE_JSON", "40000").cast(SMALLINT()),
- "CAST(PARSE_JSON('40000') AS SMALLINT)",
- (short) -25536,
+ call("PARSE_JSON", "42").cast(BIGINT()),
+ "CAST(PARSE_JSON('42') AS BIGINT)",
+ 42L,
+ BIGINT().notNull())
+ .testResult(
+ call("PARSE_JSON", "42").cast(FLOAT()),
+ "CAST(PARSE_JSON('42') AS FLOAT)",
+ 42.0f,
+ FLOAT().notNull())
+ .testResult(
+ call("PARSE_JSON", "42").cast(DOUBLE()),
+ "CAST(PARSE_JSON('42') AS DOUBLE)",
+ 42.0d,
+ DOUBLE().notNull())
+ // An out-of-range value is rejected rather than
wrapped.
+ .testResult(
+ call("PARSE_JSON", "1000").cast(SMALLINT()),
+ "CAST(PARSE_JSON('1000') AS SMALLINT)",
+ (short) 1000,
SMALLINT().notNull())
+ .testTableApiRuntimeError(
+ call("PARSE_JSON", "1000").cast(TINYINT()),
"overflowed")
+ .testSqlRuntimeError("CAST(PARSE_JSON('1000') AS
TINYINT)", "overflowed")
.testResult(
- call("PARSE_JSON", "128").cast(TINYINT()),
- "CAST(PARSE_JSON('128') AS TINYINT)",
- (byte) -128,
- TINYINT().notNull())
+ call("PARSE_JSON", "1000").tryCast(TINYINT()),
+ "TRY_CAST(PARSE_JSON('1000') AS TINYINT)",
+ null,
+ TINYINT())
+ // A decimal reaches an integer target when it is
already integral.
.testResult(
- call("PARSE_JSON", "2147483648").cast(INT()),
- "CAST(PARSE_JSON('2147483648') AS INT)",
- -2147483648,
+ call("PARSE_JSON", "7.0").cast(INT()),
+ "CAST(PARSE_JSON('7.0') AS INT)",
+ 7,
INT().notNull())
+ // A fractional value is rejected, since converting
would drop digits.
+ .testTableApiRuntimeError(
+ call("PARSE_JSON", "123.456").cast(INT()),
"lose precision")
.testResult(
- 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.
+ call("PARSE_JSON", "123.456").tryCast(INT()),
+ "TRY_CAST(PARSE_JSON('123.456') AS INT)",
+ null,
+ INT())
+ // A DECIMAL target has to hold the value exactly.
.testResult(
- call("PARSE_JSON", "1e20").cast(INT()),
- "CAST(PARSE_JSON('1e20') AS INT)",
- 2147483647,
- INT().notNull())
+ call("PARSE_JSON", "123.456").cast(DECIMAL(6,
3)),
+ "CAST(PARSE_JSON('123.456') AS DECIMAL(6, 3))",
+ new BigDecimal("123.456"),
+ DECIMAL(6, 3).notNull())
+ .testTableApiRuntimeError(
+ call("PARSE_JSON", "123.456").cast(DECIMAL(6,
2)), "lose precision")
.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.
+ call("PARSE_JSON",
"123.456").tryCast(DECIMAL(6, 2)),
+ "TRY_CAST(PARSE_JSON('123.456') AS DECIMAL(6,
2))",
+ null,
+ DECIMAL(6, 2))
+ .testTableApiRuntimeError(
+ call("PARSE_JSON", "123.456").cast(DECIMAL(5,
3)), "overflowed")
.testResult(
- call("PARSE_JSON", "1e40").cast(FLOAT()),
- "CAST(PARSE_JSON('1e40') AS FLOAT)",
- Float.POSITIVE_INFINITY,
+ call("PARSE_JSON",
"123.456").tryCast(DECIMAL(5, 3)),
+ "TRY_CAST(PARSE_JSON('123.456') AS DECIMAL(5,
3))",
+ null,
+ DECIMAL(5, 3))
+ // An integer is exact, so it reaches a DECIMAL that
has room for it.
+ .testResult(
+ call("PARSE_JSON", "42").cast(DECIMAL(5, 2)),
+ "CAST(PARSE_JSON('42') AS DECIMAL(5, 2))",
+ new BigDecimal("42.00"),
+ DECIMAL(5, 2).notNull())
+ // A decimal reaches an approximate target, where
losing digits is expected.
+ .testResult(
+ call("PARSE_JSON", "123.456").cast(FLOAT()),
+ "CAST(PARSE_JSON('123.456') AS FLOAT)",
+ 123.456f,
FLOAT().notNull())
- // DECIMAL overflow yields NULL instead of wrapping;
TRY_CAST surfaces it.
.testResult(
- call("PARSE_JSON",
"123.456").tryCast(DECIMAL(4, 2)),
- "TRY_CAST(PARSE_JSON('123.456') AS DECIMAL(4,
2))",
+ call("PARSE_JSON", "123.456").cast(DOUBLE()),
+ "CAST(PARSE_JSON('123.456') AS DOUBLE)",
+ 123.456d,
+ DOUBLE().notNull())
+ // A magnitude the target cannot represent is still
rejected.
+ .testTableApiRuntimeError(
+ call("PARSE_JSON", "1e40").cast(FLOAT()),
"overflowed")
+ .testResult(
+ call("PARSE_JSON", "1e40").tryCast(FLOAT()),
+ "TRY_CAST(PARSE_JSON('1e40') AS FLOAT)",
null,
- DECIMAL(4, 2))
+ FLOAT())
.testResult(
- call("PARSE_JSON", "42").cast(BIGINT()),
- "CAST(PARSE_JSON('42') AS BIGINT)",
- 42L,
- BIGINT().notNull())
+ call("PARSE_JSON", "1e20").cast(DOUBLE()),
+ "CAST(PARSE_JSON('1e20') AS DOUBLE)",
+ 1e20,
+ DOUBLE().notNull())
.testResult(
call("PARSE_JSON", "true").cast(BOOLEAN()),
"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())
+ // The rendering matches a regular cast of the stored
kind, so a boolean
+ // becomes TRUE rather than the JSON true.
+ .testResult(
+ call("PARSE_JSON", "true").cast(STRING()),
+ "CAST(PARSE_JSON('true') AS STRING)",
+ "TRUE",
+ STRING().notNull())
+ // An object or array has no scalar value, so the
error points to
+ // JSON_STRING.
+ .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())
+ // A bounded CHAR/VARCHAR target trims a longer value,
and CHAR pads a
+ // shorter one to its fixed width, the same as a
regular cast into it.
+ .testResult(
+ call("PARSE_JSON", "\"ab\"").cast(VARCHAR(3)),
+ "CAST(PARSE_JSON('\"ab\"') AS VARCHAR(3))",
+ "ab",
+ VARCHAR(3).notNull())
+ .testResult(
+ call("PARSE_JSON",
"\"foobar\"").cast(VARCHAR(3)),
+ "CAST(PARSE_JSON('\"foobar\"') AS VARCHAR(3))",
+ "foo",
+ VARCHAR(3).notNull())
+ .testResult(
+ call("PARSE_JSON",
"\"foobar\"").tryCast(VARCHAR(3)),
+ "TRY_CAST(PARSE_JSON('\"foobar\"') AS
VARCHAR(3))",
+ "foo",
+ VARCHAR(3))
+ .testResult(
+ call("PARSE_JSON", "\"abc\"").cast(CHAR(3)),
+ "CAST(PARSE_JSON('\"abc\"') AS CHAR(3))",
+ "abc",
+ CHAR(3).notNull())
+ .testResult(
+ call("PARSE_JSON", "\"abcdef\"").cast(CHAR(3)),
+ "CAST(PARSE_JSON('\"abcdef\"') AS CHAR(3))",
+ "abc",
+ CHAR(3).notNull())
+ .testResult(
+ call("PARSE_JSON", "\"ab\"").cast(CHAR(5)),
+ "CAST(PARSE_JSON('\"ab\"') AS CHAR(5))",
+ "ab ",
+ CHAR(5).notNull())
+ .testResult(
+ call("PARSE_JSON", "\"ab\"").tryCast(CHAR(5)),
+ "TRY_CAST(PARSE_JSON('\"ab\"') AS CHAR(5))",
+ "ab ",
+ CHAR(5))
+ // A variant holding a JSON null casts to SQL NULL,
not to the text 'null'.
+ // The length of that text must not be checked against
the target either.
+ .testResult(
+ call("TRY_PARSE_JSON", "null").cast(STRING()),
+ "CAST(TRY_PARSE_JSON('null') AS STRING)",
+ null,
+ STRING())
+ .testResult(
+ call("PARSE_JSON", "null").tryCast(STRING()),
+ "TRY_CAST(PARSE_JSON('null') AS STRING)",
+ null,
+ STRING())
+ .testResult(
+ call("TRY_PARSE_JSON", "null").cast(CHAR(2)),
+ "CAST(TRY_PARSE_JSON('null') AS CHAR(2))",
+ null,
+ CHAR(2))
+ .testResult(
+ call("PARSE_JSON", "null").tryCast(VARCHAR(2)),
+ "TRY_CAST(PARSE_JSON('null') AS VARCHAR(2))",
+ null,
+ VARCHAR(2))
// TRY_CAST of a value whose kind does not match the
target returns NULL
.testResult(
call("PARSE_JSON", "\"foo\"").tryCast(INT()),
@@ -227,15 +373,10 @@ public class CastFunctionITCase extends
BuiltInFunctionTestBase {
BOOLEAN())
// A nullable variant with a concrete value still
casts normally.
.testResult(
- 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."));
+ call("TRY_PARSE_JSON", "42").cast(TINYINT()),
+ "CAST(TRY_PARSE_JSON('42') AS TINYINT)",
+ (byte) 42,
+ TINYINT()));
}
private static List<TestSetSpec> allTypesBasic() {
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
index aca973fa3ca..e00c99c55ce 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
@@ -161,6 +161,33 @@ class CastRulesTest {
private static final Bitmap DEFAULT_BITMAP = Bitmap.fromArray(new int[]
{0, 1, 2});
+ /** A two-byte lead followed by a byte that is not a continuation byte. */
+ private static final byte[] INVALID_UTF8 = new byte[] {(byte) 0xC3, (byte)
0x28};
+
+ /** U+1D54F, one code point but two UTF-16 units and four UTF-8 bytes. */
+ private static final String NON_BMP = "𝕏";
+
+ private static final Variant VARIANT_ARRAY =
+ Variant.newBuilder()
+ .array()
+ .add(Variant.newBuilder().of(1))
+ .add(Variant.newBuilder().of("two"))
+ .add(Variant.newBuilder().of(false))
+ .add(Variant.newBuilder().ofNull())
+ .build();
+
+ private static final Variant VARIANT_OBJECT =
+ Variant.newBuilder()
+ .object()
+ .add(
+ "k",
+ Variant.newBuilder()
+ .array()
+ .add(Variant.newBuilder().of(1))
+ .add(Variant.newBuilder().of(2))
+ .build())
+ .build();
+
private static final DataType MY_STRUCTURED_TYPE =
STRUCTURED(
MyStructuredType.class,
@@ -1544,35 +1571,138 @@ class CastRulesTest {
.fromCase(BITMAP(), DEFAULT_BITMAP,
DEFAULT_BITMAP.toBytes())
.fromCase(BITMAP(), Bitmap.empty(),
Bitmap.empty().toBytes())
.fromCase(BITMAP(), null, null),
- // From VARIANT to primitive types. Numeric targets are
lenient: a variant holding
- // any numeric kind converts to the requested numeric type
(widening and narrowing).
- // Non-numeric targets are strict: the stored kind must match,
otherwise the cast
- // fails and TRY_CAST returns null.
+ // From VARIANT to primitive types. A cast succeeds only when
the target holds the
+ // stored value unaltered, except for the approximate FLOAT
and DOUBLE.
+ // A character string renders like a regular cast of the
stored kind, so these
+ // expectations reuse the constants of the native cases above.
+ CastTestSpecBuilder.testCastTo(STRING())
+ .fromCase(VARIANT(), Variant.newBuilder().of(true),
fromString("TRUE"))
+ .fromCase(VARIANT(), Variant.newBuilder().of(false),
fromString("FALSE"))
+ .fromCase(VARIANT(), Variant.newBuilder().of("foo"),
fromString("foo"))
+ .fromCase(VARIANT(), Variant.newBuilder().of(42),
fromString("42"))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("123.456")),
+ fromString("123.456"))
+ // a small scale stays plain instead of turning into
scientific notation
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("0.0000000001")),
+ fromString("0.0000000001"))
+ .fromCase(
+ VARIANT(),
+
Variant.newBuilder().of(LocalDate.parse("2021-09-24")),
+ DATE_STRING)
+ .fromCase(
+ VARIANT(),
+
Variant.newBuilder().of(TIMESTAMP.toLocalDateTime()),
+ TIMESTAMP_STRING)
+ .fromCase(
+ VARIANT(),
+ CET_CONTEXT,
+ Variant.newBuilder().of(TIMESTAMP.toInstant()),
+ TIMESTAMP_STRING_CET)
+ // a binary value is read as UTF-8, like a regular
BINARY to string cast
+ .fromCase(
+ VARIANT(),
+
Variant.newBuilder().of("hello".getBytes(StandardCharsets.UTF_8)),
+ fromString("hello"))
+ // a multi-byte sequence passes the well-formedness
check unchanged
+ .fromCase(
+ VARIANT(),
+
Variant.newBuilder().of("héllo".getBytes(StandardCharsets.UTF_8)),
+ fromString("héllo"))
+ // bytes that are not valid UTF-8 are rejected rather
than decoded into
+ // the U+FFFD replacement character
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(INVALID_UTF8),
+ TableRuntimeException.class)
+ // an object or an array has no scalar rendering and
fails the cast
+ .fail(VARIANT(), VARIANT_ARRAY,
TableRuntimeException.class)
+ .fail(VARIANT(), VARIANT_OBJECT,
TableRuntimeException.class)
+ // printing is not a cast and cannot fail, so it
renders JSON instead,
+ // which leaves a stored string quoted
+ .fromCasePrinting(
+ VARIANT(), VARIANT_ARRAY,
fromString("[1,\"two\",false,null]"))
+ .fromCasePrinting(VARIANT(), VARIANT_OBJECT,
fromString("{\"k\":[1,2]}"))
+ .fromCasePrinting(
+ VARIANT(), Variant.newBuilder().of("foo"),
fromString("\"foo\""))
+ .fromCasePrinting(VARIANT(),
Variant.newBuilder().of(42), fromString("42")),
+ // A bounded character target pads and trims like any other
cast into it, and its
+ // length counts code points, so a character outside the BMP
fills one position
+ // even though it occupies two UTF-16 units.
+ CastTestSpecBuilder.testCastTo(CHAR(1))
+ .fromCase(VARIANT(), Variant.newBuilder().of("x"),
fromString("x"))
+ .fromCase(VARIANT(), Variant.newBuilder().of(NON_BMP),
fromString(NON_BMP))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(NON_BMP + NON_BMP),
+ fromString(NON_BMP))
+ .fromCase(
+ VARIANT(),
Variant.newBuilder().of("abcdefghij"), fromString("a")),
+ CastTestSpecBuilder.testCastTo(CHAR(5))
+ // shorter than the target, so it is padded to the
fixed width
+ .fromCase(VARIANT(), Variant.newBuilder().of("ab"),
fromString("ab "))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of("abcdefghij"),
+ fromString("abcde")),
+ CastTestSpecBuilder.testCastTo(VARCHAR(2))
+ .fromCase(VARIANT(), Variant.newBuilder().of(NON_BMP),
fromString(NON_BMP))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(NON_BMP + NON_BMP),
+ fromString(NON_BMP + NON_BMP))
+ // longer than the target, so it is trimmed rather
than rejected, and a
+ // variable width target is not padded
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(NON_BMP + NON_BMP +
NON_BMP),
+ fromString(NON_BMP + NON_BMP))
+ .fromCase(VARIANT(), Variant.newBuilder().of("a"),
fromString("a")),
CastTestSpecBuilder.testCastTo(BOOLEAN())
.fromCase(VARIANT(), Variant.newBuilder().of(true),
true)
.fromCase(VARIANT(), Variant.newBuilder().of(false),
false)
.fail(VARIANT(), Variant.newBuilder().of(1),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(TINYINT())
.fromCase(VARIANT(), Variant.newBuilder().of((byte)
42), (byte) 42)
+ // a wider integer kind narrows while the value is in
range
.fromCase(VARIANT(), Variant.newBuilder().of(42),
(byte) 42)
+ // out of range is rejected instead of wrapping
+ .fail(VARIANT(), Variant.newBuilder().of(1000),
TableRuntimeException.class)
.fail(VARIANT(), Variant.newBuilder().of("x"),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(SMALLINT())
.fromCase(VARIANT(), Variant.newBuilder().of((short)
42), (short) 42)
.fromCase(VARIANT(), Variant.newBuilder().of((byte)
42), (short) 42)
+ .fromCase(VARIANT(), Variant.newBuilder().of(1000),
(short) 1000)
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(40000),
+ TableRuntimeException.class)
.fail(
VARIANT(),
Variant.newBuilder().of(true),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(INT())
- // widening: a JSON integer is stored in the smallest
type but still casts
- // up
+ .fromCase(VARIANT(), Variant.newBuilder().of(42), 42)
+ // every integer kind converts as long as the value
fits
.fromCase(VARIANT(), Variant.newBuilder().of((byte)
42), 42)
.fromCase(VARIANT(), Variant.newBuilder().of((short)
42), 42)
- .fromCase(VARIANT(), Variant.newBuilder().of(42), 42)
.fromCase(VARIANT(), Variant.newBuilder().of(42L), 42)
- // narrowing from a floating point or decimal value
truncates
- .fromCase(VARIANT(), Variant.newBuilder().of(3.9d), 3)
- .fromCase(VARIANT(), Variant.newBuilder().of(new
BigDecimal("7.2")), 7)
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(2147483648L),
+ TableRuntimeException.class)
+ // an approximate or decimal kind converts when the
value is integral
+ .fromCase(VARIANT(), Variant.newBuilder().of(7.0d), 7)
+ .fromCase(VARIANT(), Variant.newBuilder().of(new
BigDecimal("7.0")), 7)
+ // a fractional value would have to be rounded away,
so it is rejected
+ .fail(VARIANT(), Variant.newBuilder().of(7.2d),
TableRuntimeException.class)
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(new BigDecimal("7.2")),
+ TableRuntimeException.class)
// a non-numeric variant cannot be cast to a number
.fail(
VARIANT(),
@@ -1587,14 +1717,28 @@ class CastRulesTest {
.fromCase(VARIANT(), Variant.newBuilder().of(42), 42L)
.fail(VARIANT(), Variant.newBuilder().of("x"),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(FLOAT())
+ // every numeric kind reaches an approximate target
.fromCase(VARIANT(), Variant.newBuilder().of(1.5f),
1.5f)
.fromCase(VARIANT(), Variant.newBuilder().of(1.5d),
1.5f)
.fromCase(VARIANT(), Variant.newBuilder().of(3), 3.0f)
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("123.456")),
+ 123.456f)
+ // a magnitude a FLOAT cannot represent is still
rejected
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(1e40d),
+ TableRuntimeException.class)
.fail(VARIANT(), Variant.newBuilder().of("x"),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(DOUBLE())
.fromCase(VARIANT(), Variant.newBuilder().of(1.5d),
1.5d)
.fromCase(VARIANT(), Variant.newBuilder().of(1.5f),
1.5d)
.fromCase(VARIANT(), Variant.newBuilder().of(3), 3.0d)
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("123.456")),
+ 123.456d)
.fail(VARIANT(), Variant.newBuilder().of("x"),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(DECIMAL(5, 2))
.fromCase(VARIANT(), null, null)
@@ -1602,10 +1746,23 @@ class CastRulesTest {
VARIANT(),
Variant.newBuilder().of(new
BigDecimal("123.45")),
DecimalData.fromBigDecimal(new
BigDecimal("123.45"), 5, 2))
+ // trailing zeros may be appended to reach the target
scale
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("123.4")),
+ DecimalData.fromBigDecimal(new
BigDecimal("123.40"), 5, 2))
+ // an integer is exact, so it converts when it fits
.fromCase(
VARIANT(),
Variant.newBuilder().of(42),
- DecimalData.fromBigDecimal(new
BigDecimal("42"), 5, 2))
+ DecimalData.fromBigDecimal(new
BigDecimal("42.00"), 5, 2))
+ // a scale that would have to round is rejected
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(new
BigDecimal("123.456")),
+ TableRuntimeException.class)
+ // an approximate kind is not read as a decimal
+ .fail(VARIANT(), Variant.newBuilder().of(1.5d),
TableRuntimeException.class)
.fail(VARIANT(), Variant.newBuilder().of("x"),
TableRuntimeException.class),
CastTestSpecBuilder.testCastTo(BYTES())
.fromCase(VARIANT(), null, null)
@@ -1613,6 +1770,9 @@ class CastRulesTest {
VARIANT(),
Variant.newBuilder().of(new byte[] {1, 2, 3}),
new byte[] {1, 2, 3})
+ // the raw bytes stay reachable when the character
string cast rejects
+ // them, which is what makes this the way to inspect
such a value
+ .fromCase(VARIANT(),
Variant.newBuilder().of(INVALID_UTF8), INVALID_UTF8)
.fail(
VARIANT(),
Variant.newBuilder().of("foo"),
@@ -1630,13 +1790,82 @@ class CastRulesTest {
Variant.newBuilder().of(LocalDateTime.of(2020,
1, 1, 12, 0, 0)),
TimestampData.fromLocalDateTime(
LocalDateTime.of(2020, 1, 1, 12, 0,
0)))
- .fail(VARIANT(), Variant.newBuilder().of(1),
TableRuntimeException.class),
+ .fail(VARIANT(), Variant.newBuilder().of(1),
TableRuntimeException.class)
+ // a TIMESTAMP_LTZ is a different kind and is not read
as a TIMESTAMP
+ .fail(
+ VARIANT(),
+
Variant.newBuilder().of(Instant.ofEpochSecond(1_600_000_000L)),
+ TableRuntimeException.class),
+ // A variant keeps microseconds, so fractional seconds beyond
the target precision
+ // are truncated, matching a regular cast into a narrower
TIMESTAMP.
+ CastTestSpecBuilder.testCastTo(TIMESTAMP(3))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(LocalDateTime.of(2020,
1, 1, 12, 0, 0)),
+ TimestampData.fromLocalDateTime(
+ LocalDateTime.of(2020, 1, 1, 12, 0,
0)))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder()
+ .of(LocalDateTime.of(2020, 1, 1, 12,
0, 0, 123000000)),
+ TimestampData.fromLocalDateTime(
+ LocalDateTime.of(2020, 1, 1, 12, 0, 0,
123000000)))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder()
+ .of(LocalDateTime.of(2020, 1, 1, 12,
0, 0, 123456000)),
+ TimestampData.fromLocalDateTime(
+ LocalDateTime.of(2020, 1, 1, 12, 0, 0,
123000000))),
+ CastTestSpecBuilder.testCastTo(TIMESTAMP(0))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder()
+ .of(LocalDateTime.of(2020, 1, 1, 12,
0, 0, 123000000)),
+ TimestampData.fromLocalDateTime(
+ LocalDateTime.of(2020, 1, 1, 12, 0,
0))),
CastTestSpecBuilder.testCastTo(TIMESTAMP_LTZ())
.fromCase(
VARIANT(),
Variant.newBuilder().of(Instant.ofEpochSecond(1_600_000_000L)),
TimestampData.fromInstant(Instant.ofEpochSecond(1_600_000_000L)))
- .fail(VARIANT(), Variant.newBuilder().of(1),
TableRuntimeException.class));
+ .fail(VARIANT(), Variant.newBuilder().of(1),
TableRuntimeException.class)
+ // a TIMESTAMP is not read as a TIMESTAMP_LTZ either
+ .fail(
+ VARIANT(),
+ Variant.newBuilder().of(LocalDateTime.of(2020,
1, 1, 12, 0, 0)),
+ TableRuntimeException.class),
+ CastTestSpecBuilder.testCastTo(TIMESTAMP_LTZ(3))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder()
+
.of(Instant.ofEpochSecond(1_600_000_000L, 123456000)),
+ TimestampData.fromInstant(
+ Instant.ofEpochSecond(1_600_000_000L,
123000000))),
+ // A binary target pads a shorter value and truncates a longer
one, matching a
+ // regular cast into the same type.
+ CastTestSpecBuilder.testCastTo(BINARY(4))
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new byte[] {1, 2, 3,
4}),
+ new byte[] {1, 2, 3, 4})
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new byte[] {1, 2}),
+ new byte[] {1, 2, 0, 0})
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new byte[] {1, 2, 3,
4, 5, 6}),
+ new byte[] {1, 2, 3, 4}),
+ CastTestSpecBuilder.testCastTo(VARBINARY(4))
+ // a variable width target is trimmed but never padded
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new byte[] {1, 2}),
+ new byte[] {1, 2})
+ .fromCase(
+ VARIANT(),
+ Variant.newBuilder().of(new byte[] {1, 2, 3,
4, 5, 6}),
+ new byte[] {1, 2, 3, 4}));
}
@TestFactory
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 00000000000..89f112348fe
--- /dev/null
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java
@@ -0,0 +1,369 @@
+/*
+ * 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.table.data.TimestampData;
+import org.apache.flink.table.data.binary.BinaryStringData;
+import org.apache.flink.table.data.binary.BinaryStringDataUtil;
+import org.apache.flink.table.data.binary.StringUtf8Utils;
+import org.apache.flink.table.utils.DateTimeUtils;
+import org.apache.flink.types.variant.Variant;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.TimeZone;
+
+/**
+ * Runtime helpers for casting a {@code VARIANT} value to a SQL type.
+ *
+ * <p>A cast never reinterprets the stored value: one kind is not read as
another, and a numeric
+ * value is never wrapped or rounded to make it fit. Any numeric kind
therefore reaches an integer
+ * target as long as the value is integral and in range. {@code FLOAT} and
{@code DOUBLE} are the
+ * exception to exactness: they are approximate by definition, so they accept
any numeric kind and
+ * reject only a magnitude they cannot represent at all.
+ *
+ * <p>A length or a precision is not part of the value in that sense, so it
follows the rules of a
+ * regular cast into the same type. A value longer than the target is trimmed,
fractional seconds
+ * beyond the target precision are truncated, and the fixed width targets
{@code CHAR(n)} and {@code
+ * BINARY(n)} pad a shorter value.
+ */
+@Internal
+public final class VariantCastUtils {
+
+ /**
+ * The magnitude 2^63, the exclusive bound for a {@code double} that still
fits a {@code long}.
+ * Taken from {@link Long#MIN_VALUE} because that is exactly -2^63,
whereas widening {@link
+ * Long#MAX_VALUE} would reach the same number only by rounding up.
+ */
+ private static final double LONG_MAGNITUDE_LIMIT = -(double)
Long.MIN_VALUE;
+
+ /** A variant stores a timestamp with microsecond precision. */
+ private static final int TIMESTAMP_PRECISION = 6;
+
+ private VariantCastUtils() {}
+
+ /**
+ * Reads a numeric variant as a {@code long} and checks it against the
target range. An
+ * approximate or decimal value is accepted only when it is already
integral, so nothing is
+ * rounded away.
+ */
+ public static long toIntegral(Variant variant, long min, long max, String
targetType) {
+ final long value;
+ switch (variant.getType()) {
+ case TINYINT:
+ case SMALLINT:
+ case INT:
+ case BIGINT:
+ value = ((Number) variant.get()).longValue();
+ break;
+ case FLOAT:
+ case DOUBLE:
+ final double approximate = ((Number)
variant.get()).doubleValue();
+ // Below 2^63 the narrowing conversion stays exact. The
comparison is negated so
+ // that NaN fails it too.
+ if (!(Math.abs(approximate) < LONG_MAGNITUDE_LIMIT)) {
+ throw overflow(approximate, targetType);
+ }
+ value = (long) approximate;
+ if (value != approximate) {
+ throw lossyCast(approximate, targetType);
+ }
+ break;
+ case DECIMAL:
+ final BigDecimal decimal = variant.getDecimal();
+ final BigDecimal integral;
+ try {
+ // UNNECESSARY throws unless the value is already integral.
+ integral = decimal.setScale(0, RoundingMode.UNNECESSARY);
+ } catch (ArithmeticException e) {
+ throw lossyCast(decimal, targetType);
+ }
+ try {
+ // longValueExact rejects a value that does not fit a long
instead of returning
+ // its low-order bits.
+ value = integral.longValueExact();
+ } catch (ArithmeticException e) {
+ throw overflow(decimal, targetType);
+ }
+ break;
+ default:
+ throw unsupportedKind(variant, targetType);
+ }
+ if (value < min || value > max) {
+ throw overflow(value, targetType);
+ }
+ return value;
+ }
+
+ /**
+ * Reads any numeric variant as a {@code float}. Dropping decimal digits
is expected of an
+ * approximate type, but a magnitude outside the {@code FLOAT} range is
rejected.
+ */
+ public static float toFloat(Variant variant) {
+ final float value = numeric(variant, "FLOAT").floatValue();
+ if (!Float.isFinite(value)) {
+ throw overflow(variant.get(), "FLOAT");
+ }
+ return value;
+ }
+
+ /** Reads any numeric variant as a {@code double}. See {@link
#toFloat(Variant)}. */
+ public static double toDouble(Variant variant) {
+ final double value = numeric(variant, "DOUBLE").doubleValue();
+ if (!Double.isFinite(value)) {
+ throw overflow(variant.get(), "DOUBLE");
+ }
+ return value;
+ }
+
+ /**
+ * Reads an integer or decimal variant as the target {@code DECIMAL}. The
value has to fit the
+ * precision and scale without rounding, although trailing zeros may be
appended to reach the
+ * scale.
+ */
+ public static DecimalData toDecimal(Variant variant, int precision, int
scale) {
+ final BigDecimal value;
+ switch (variant.getType()) {
+ case TINYINT:
+ case SMALLINT:
+ case INT:
+ case BIGINT:
+ value = BigDecimal.valueOf(((Number)
variant.get()).longValue());
+ break;
+ case DECIMAL:
+ value = variant.getDecimal();
+ break;
+ default:
+ throw unsupportedKind(variant, decimalTarget(precision,
scale));
+ }
+ // The integral part must fit the digits the target reserves for it.
+ if (value.precision() - value.scale() > precision - scale) {
+ throw overflow(value, decimalTarget(precision, scale));
+ }
+ final BigDecimal rescaled;
+ try {
+ // UNNECESSARY throws unless the value fits the target scale
exactly.
+ rescaled = value.setScale(scale, RoundingMode.UNNECESSARY);
+ } catch (ArithmeticException e) {
+ throw lossyCast(value, decimalTarget(precision, scale));
+ }
+ final DecimalData decimal = DecimalData.fromBigDecimal(rescaled,
precision, scale);
+ if (decimal == null) {
+ throw overflow(value, decimalTarget(precision, scale));
+ }
+ return decimal;
+ }
+
+ private static String decimalTarget(int precision, int scale) {
+ return String.format("DECIMAL(%d, %d)", precision, scale);
+ }
+
+ /**
+ * Reads a timestamp variant as the target {@code TIMESTAMP}. A variant
keeps microseconds, so
+ * fractional seconds beyond the target precision are truncated, the same
as a regular {@code
+ * TIMESTAMP} to {@code TIMESTAMP(p)} cast.
+ */
+ public static TimestampData toTimestamp(Variant variant, int precision) {
+ if (variant.getType() != Variant.Type.TIMESTAMP) {
+ throw unsupportedKind(variant, String.format("TIMESTAMP(%d)",
precision));
+ }
+ return DateTimeUtils.truncate(
+ TimestampData.fromLocalDateTime(variant.getDateTime()),
precision);
+ }
+
+ /** Reads a timestamp with local time zone variant. See {@link
#toTimestamp(Variant, int)}. */
+ public static TimestampData toTimestampLtz(Variant variant, int precision)
{
+ if (variant.getType() != Variant.Type.TIMESTAMP_LTZ) {
+ throw unsupportedKind(variant, String.format("TIMESTAMP_LTZ(%d)",
precision));
+ }
+ return
DateTimeUtils.truncate(TimestampData.fromInstant(variant.getInstant()),
precision);
+ }
+
+ /**
+ * Reads a binary variant as the target binary type. {@code BINARY} is
fixed width, so a shorter
+ * value is padded with zero bytes, and either target truncates a value
longer than {@code
+ * targetLength}. This matches a regular cast into the same type.
+ */
+ public static byte[] toBytes(Variant variant, int targetLength, boolean
fixedLength) {
+ final byte[] value = variant.getBytes();
+ if (fixedLength) {
+ return value.length == targetLength ? value : Arrays.copyOf(value,
targetLength);
+ }
+ return value.length <= targetLength ? value : Arrays.copyOf(value,
targetLength);
+ }
+
+ /**
+ * Casts a scalar {@code VARIANT} to a character string, rendering the
value the way a regular
+ * SQL cast of the stored kind would. A value longer than {@code
targetLength} is trimmed, and a
+ * {@code CHAR} target pads a shorter value to its fixed width. Both are
measured in code
+ * points, so a character outside the BMP fills a single position even
though it occupies two
+ * UTF-16 units.
+ *
+ * <p>A stored binary value has to be well-formed UTF-8, since a character
string cannot carry
+ * bytes that no character maps to. Invalid input is rejected rather than
decoded into {@code
+ * U+FFFD}, which would silently substitute a character the value never
held.
+ *
+ * @param sessionZone the session time zone, applied to a {@code
TIMESTAMP_LTZ} value
+ */
+ public static BinaryStringData toStringValue(
+ Variant variant, TimeZone sessionZone, int targetLength, boolean
charTarget) {
+ final String value = getVariantTypeAsString(variant, sessionZone,
targetLength, charTarget);
+ // numChars and substring both count code points, so a character
outside the BMP fills one
+ // position rather than the two UTF-16 units it occupies.
+ final BinaryStringData result = BinaryStringData.fromString(value);
+ final int length = result.numChars();
+ if (length > targetLength) {
+ return result.substring(0, targetLength);
+ }
+ if (charTarget && length < targetLength) {
+ return BinaryStringDataUtil.concat(
+ result, BinaryStringData.blankString(targetLength -
length));
+ }
+ return result;
+ }
+
+ private static String getVariantTypeAsString(
+ final Variant variant,
+ final TimeZone sessionZone,
+ final int targetLength,
+ final boolean charTarget) {
+ final String value;
+ switch (variant.getType()) {
+ case BOOLEAN:
+ value = variant.getBoolean() ? "TRUE" : "FALSE";
+ break;
+ case TINYINT:
+ case SMALLINT:
+ case INT:
+ case BIGINT:
+ case FLOAT:
+ case DOUBLE:
+ value = variant.get().toString();
+ break;
+ case DECIMAL:
+ // toPlainString rather than toString, so that a small scale
is not rendered in
+ // scientific notation, matching a regular DECIMAL to string
cast.
+ value = variant.getDecimal().toPlainString();
+ break;
+ case STRING:
+ value = variant.getString();
+ break;
+ case BYTES:
+ // SQL reads a binary value as UTF-8, the same as a regular
BINARY to string cast.
+ final byte[] utf8 = variant.getBytes();
+ final int invalidAt =
+ StringUtf8Utils.firstInvalidUtf8ByteIndex(utf8, 0,
utf8.length);
+ if (invalidAt >= 0) {
+ throw new TableRuntimeException(
+ String.format(
+ "Cannot cast the VARIANT binary value to
%s because it is not "
+ + "valid UTF-8; the first invalid
byte is at index %d "
+ + "of %d. Cast to BYTES to inspect
the raw value, or "
+ + "wrap that in MAKE_VALID_UTF8 to
replace every "
+ + "invalid byte with the U+FFFD
replacement character.",
+ characterTarget(targetLength, charTarget),
+ invalidAt,
+ utf8.length));
+ }
+ value = new String(utf8, StandardCharsets.UTF_8);
+ break;
+ case DATE:
+ value = DateTimeUtils.formatDate((int)
variant.getDate().toEpochDay());
+ break;
+ case TIMESTAMP:
+ // A wall-clock value needs no zone shift, which is what
UTC_ZONE achieves here. A
+ // variant keeps microseconds, so the precision is always 6.
+ value =
+ DateTimeUtils.formatTimestamp(
+
TimestampData.fromLocalDateTime(variant.getDateTime()),
+ DateTimeUtils.UTC_ZONE,
+ TIMESTAMP_PRECISION);
+ break;
+ case TIMESTAMP_LTZ:
+ value =
+ DateTimeUtils.formatTimestamp(
+
TimestampData.fromInstant(variant.getInstant()),
+ sessionZone,
+ TIMESTAMP_PRECISION);
+ break;
+ case NULL:
+ // Only reachable for a NOT NULL target. A nullable target
maps a null-valued
+ // variant to SQL NULL before this method is called.
+ throw new TableRuntimeException(
+ String.format(
+ "Cannot cast a VARIANT null value to %s
because the target does not "
+ + "accept NULL.",
+ characterTarget(targetLength, charTarget)));
+ default:
+ // An object or array has no scalar rendering.
+ 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()));
+ }
+ return value;
+ }
+
+ private static String characterTarget(int targetLength, boolean
charTarget) {
+ return String.format("%s(%d)", charTarget ? "CHAR" : "VARCHAR",
targetLength);
+ }
+
+ private static Number numeric(Variant variant, String targetType) {
+ switch (variant.getType()) {
+ case TINYINT:
+ case SMALLINT:
+ case INT:
+ case BIGINT:
+ case FLOAT:
+ case DOUBLE:
+ case DECIMAL:
+ return (Number) variant.get();
+ default:
+ throw unsupportedKind(variant, targetType);
+ }
+ }
+
+ private static TableRuntimeException unsupportedKind(Variant variant,
String targetType) {
+ return new TableRuntimeException(
+ String.format(
+ "Cannot cast a VARIANT %s value to %s. A VARIANT cast
does not change the "
+ + "type of the stored value, so cast it to its
own type first and "
+ + "then convert with a regular cast.",
+ variant.getType(), targetType));
+ }
+
+ private static TableRuntimeException overflow(Object value, String
targetType) {
+ return new TableRuntimeException(
+ String.format("Casting the VARIANT value %s to %s
overflowed.", value, targetType));
+ }
+
+ private static TableRuntimeException lossyCast(Object 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));
+ }
+}