This is an automated email from the ASF dual-hosted git repository. AHeise pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/flink.git
commit e785c495e43c5e5b1357c8f829204a3272d1cd70 Author: Ramin Gharib <[email protected]> AuthorDate: Mon Sep 28 11:26:36 2026 +0200 [FLINK-40825][table] Support casting primitive types to VARIANT CAST and TRY_CAST now convert BOOLEAN, numeric, character string, binary string, DATE, TIME, TIMESTAMP, TIMESTAMP_LTZ, and UUID values to VARIANT. SqlCastFunction rejected every cast to VARIANT, and now routes a VARIANT target to LogicalTypeCasts, which lists the supported sources explicitly. A type without a VARIANT kind, such as INTERVAL, is rejected at validation, and the cast is explicit only. The value keeps the kind of its SQL type, so an integer keeps its width and a string is [...] A timestamp keeps its declared precision: up to 6 it is stored with microseconds and above with nanoseconds. Nanoseconds only cover 1677-09-21 to 2262-04-11, so such a value outside that range fails the cast. A VARIANT holds at most 16 MiB, so a longer string or binary value fails the cast too. The rule reports a type as fallible when its precision or declared length allows such a value, so TRY_CAST returns NULL for it. A constructed cast checks each child with the same rules, so a cast such as ARRAY<INT> to ARRAY<VARIANT> works element by element. Casting a whole ARRAY, MAP, or ROW into one VARIANT follows in FLINK-40826. --- docs/content.zh/docs/sql/reference/data-types.md | 87 +++++-- docs/content/docs/sql/reference/data-types.md | 87 +++++-- .../types/logical/utils/LogicalTypeCasts.java | 27 ++ .../flink/table/types/LogicalTypeCastsTest.java | 69 ++++- .../apache/calcite/sql/fun/SqlCastFunction.java | 14 +- .../functions/casting/CastRuleProvider.java | 1 + .../casting/PrimitiveToVariantCastRule.java | 179 +++++++++++++ .../planner/functions/CastFunctionITCase.java | 277 +++++++++++++++++++++ .../functions/casting/CastRuleProviderTest.java | 73 ++++++ .../planner/functions/casting/CastRulesTest.java | 176 +++++++++++++ .../table/runtime/functions/VariantCastUtils.java | 147 ++++++++++- .../runtime/functions/VariantCastUtilsTest.java | 27 ++ 12 files changed, 1116 insertions(+), 48 deletions(-) diff --git a/docs/content.zh/docs/sql/reference/data-types.md b/docs/content.zh/docs/sql/reference/data-types.md index 4858d42d889..e5344621c3f 100644 --- a/docs/content.zh/docs/sql/reference/data-types.md +++ b/docs/content.zh/docs/sql/reference/data-types.md @@ -1517,8 +1517,9 @@ 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. +`TIME`, `TIMESTAMP`, `TIMESTAMP_LTZ`, `BYTES`, `UUID`, or a nested array or object. `TIME` is stored +with microsecond precision, and `TIMESTAMP` and `TIMESTAMP_LTZ` with microsecond or nanosecond +precision. The `PARSE_JSON` function produces only the kinds that JSON syntax can express: @@ -1542,8 +1543,8 @@ 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)`. +To store one of these values, cast the number to a `VARIANT` instead of parsing it, for example +`CAST(CAST('Infinity' AS DOUBLE) AS VARIANT)`. 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 @@ -1669,6 +1670,56 @@ CAST(o AS MAP<STRING, VARIANT>) -- values kept as variants, the variant null in CAST(o AS MAP<INT, STRING>) -- fails at validation, a MAP key must be a character string ``` +A scalar value can also be cast to a `VARIANT` with `CAST` or `TRY_CAST`. Only a type that a +`VARIANT` kind holds without loss is supported, and any other type, such as `INTERVAL`, `RAW`, or +`BITMAP`, is rejected at validation. The value keeps the kind of its SQL type: + +| Input type | Stored `VARIANT` kind | +|--------------------------------------------|-------------------------------------------------| +| `BOOLEAN` | `BOOLEAN` | +| `TINYINT`, `SMALLINT`, `INTEGER`, `BIGINT` | `TINYINT`, `SMALLINT`, `INT`, `BIGINT` | +| `FLOAT`, `DOUBLE`, `DECIMAL` | `FLOAT`, `DOUBLE`, `DECIMAL` | +| `CHAR`, `VARCHAR`, `STRING` | `STRING` | +| `BINARY`, `VARBINARY`, `BYTES` | `BYTES` | +| `DATE`, `TIME`, `UUID` | `DATE`, `TIME`, `UUID` | +| `TIMESTAMP`, `TIMESTAMP_LTZ` | `TIMESTAMP`, `TIMESTAMP_LTZ` | + +- An integer keeps the width of its SQL type, so a `BIGINT` is stored as a `BIGINT` even when the + value would fit a smaller kind. `PARSE_JSON('1')` instead picks the smallest kind, a `TINYINT`. + Either way it casts back to any integer type that holds the value. +- A character string is stored as a `STRING` and is never parsed. Use `PARSE_JSON` to parse JSON text. +- A `TIMESTAMP(p)` or `TIMESTAMP_LTZ(p)` keeps its declared precision. Up to a precision of 6 it is + stored with microseconds, and above with nanoseconds, even when the value has no digits below a + microsecond. Nanoseconds only cover 1677-09-21 to 2262-04-11, so for a precision above 6 a value + outside that range fails the cast, and `TRY_CAST` returns `NULL`. +- A `NaN` or infinite `FLOAT` or `DOUBLE` is stored as is, although `PARSE_JSON` rejects them. A + `VARIANT` is not limited to JSON, but JSON has no literal for them. So `JSON_STRING` fails on such + a variant, and so does writing it to a sink with the `json` or `raw` format, which fails the job. + Printing it and casting it to `STRING` show `NaN`, and it casts back to `FLOAT` or `DOUBLE` + unchanged. +- A `VARIANT` holds at most 16 MiB, so a string or binary value can have at most 16,777,211 bytes. + A longer value fails the cast, and `TRY_CAST` returns `NULL`. +- A SQL `NULL` casts to a SQL `NULL`, not to a variant null. +- The cast is never applied implicitly, so writing an `INT` into a `VARIANT` column needs an explicit + `CAST`. +- A constructed type casts element by element when its element, field, or value type casts to + `VARIANT`, for example `ARRAY<INT>` to `ARRAY<VARIANT>`. A `NULL` element stays a SQL `NULL`. A + whole `ARRAY`, `MAP`, or `ROW` does not cast into a single `VARIANT` yet. +- Two `VARIANT` values are equal only when their binary encodings match. So as a `MAP` key or a + `MULTISET` element, a `1` cast from `INT` does not match a `1` cast from `BIGINT` or parsed by + `PARSE_JSON('1')`. + +```sql +CAST(42 AS VARIANT) -- 42, stored as an INT +CAST(CAST(1 AS BIGINT) AS VARIANT) -- 1, stored as a BIGINT +CAST('{"a": 1}' AS VARIANT) -- the string '{"a": 1}', not an object +CAST(NULL AS VARIANT) -- NULL +CAST(CAST('NaN' AS DOUBLE) AS VARIANT) -- NaN, stored as a DOUBLE +CAST(INTERVAL '2' DAY AS VARIANT) -- fails at validation +CAST(ARRAY[1, NULL] AS ARRAY<VARIANT>) -- [1, NULL], each element a VARIANT, the NULL stays SQL NULL +CAST(ARRAY[1, 2] AS VARIANT) -- fails at validation, not supported yet +``` + **Declaration** {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}} @@ -1865,20 +1916,20 @@ COALESCE(TRY_CAST('non-number' AS INT), 0) --- 结果返回数字 0 的 INT 格 | Input\Target | `CHAR`¹/<br/>`VARCHAR`¹/<br/>`STRING` | `BINARY`¹/<br/>`VARBINARY`¹/<br/>`BYTES` | `BOOLEAN` | `DECIMAL` | `TINYINT` | `SMALLINT` | `INTEGER` | `BIGINT` | `FLOAT` | `DOUBLE` | `DATE` | `TIME` | `TIMESTAMP` | `TIMESTAMP_LTZ` | `INTERVAL` | `ARRAY` | `MULTISET` | `MAP` | `ROW` | `STRUCTURED` | `RAW` | `VARIANT` | `BITMAP` | |:---------------------------------------|:-------------------------------------:|:----------------------------------------:|:---------:|:---------:|:---------:|:----------:|:---------:|:--------:|:-------:|:--------:|:------:|:------:|:-----------:|:---------------:|:----------:|:-------:|:----------:|:-----:|:-----:|:------------:|:-----:|:---------:|:--------:| -| `CHAR`/<br/>`VARCHAR`/<br/>`STRING` | Y | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | N | N | N | N | N | N | N | N | N | -| `BINARY`/<br/>`VARBINARY`/<br/>`BYTES` | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `BOOLEAN` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DECIMAL` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `TINYINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | N | N | -| `SMALLINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | N | N | -| `INTEGER` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁵ | N | N | N | N | N | N | N | N | -| `BIGINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁶ | N | N | N | N | N | N | N | N | -| `FLOAT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DOUBLE` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DATE` | Y | N | N | N | N | N | N | N | N | N | Y | N | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIME` | Y | N | N | N | N | N | N | N | N | N | N | Y | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIMESTAMP` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIMESTAMP_LTZ` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | +| `CHAR`/<br/>`VARCHAR`/<br/>`STRING` | Y | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | N | N | N | N | N | N | N | ! | N | +| `BINARY`/<br/>`VARBINARY`/<br/>`BYTES` | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | ! | N | +| `BOOLEAN` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DECIMAL` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `TINYINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | Y | N | +| `SMALLINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | Y | N | +| `INTEGER` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁵ | N | N | N | N | N | N | Y | N | +| `BIGINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁶ | N | N | N | N | N | N | Y | N | +| `FLOAT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DOUBLE` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DATE` | Y | N | N | N | N | N | N | N | N | N | Y | N | Y | Y | N | N | N | N | N | N | N | Y | N | +| `TIME` | Y | N | N | N | N | N | N | N | N | N | N | Y | Y | Y | N | N | N | N | N | N | N | Y | N | +| `TIMESTAMP` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | ! | N | +| `TIMESTAMP_LTZ` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | ! | N | | `INTERVAL` | Y | N | N | N | N | N | Y⁵ | Y⁶ | N | N | N | N | N | N | Y | N | N | N | N | N | N | N | N | | `ARRAY` | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | !³ | N | N | N | N | N | N | N | | `MULTISET` | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | !³ | N | N | N | N | N | N | diff --git a/docs/content/docs/sql/reference/data-types.md b/docs/content/docs/sql/reference/data-types.md index a3b4371b4b1..ca35e7dac3f 100644 --- a/docs/content/docs/sql/reference/data-types.md +++ b/docs/content/docs/sql/reference/data-types.md @@ -1525,8 +1525,9 @@ 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. +`TIME`, `TIMESTAMP`, `TIMESTAMP_LTZ`, `BYTES`, `UUID`, or a nested array or object. `TIME` is stored +with microsecond precision, and `TIMESTAMP` and `TIMESTAMP_LTZ` with microsecond or nanosecond +precision. The `PARSE_JSON` function produces only the kinds that JSON syntax can express: @@ -1550,8 +1551,8 @@ 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)`. +To store one of these values, cast the number to a `VARIANT` instead of parsing it, for example +`CAST(CAST('Infinity' AS DOUBLE) AS VARIANT)`. 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 @@ -1677,6 +1678,56 @@ CAST(o AS MAP<STRING, VARIANT>) -- values kept as variants, the variant null in CAST(o AS MAP<INT, STRING>) -- fails at validation, a MAP key must be a character string ``` +A scalar value can also be cast to a `VARIANT` with `CAST` or `TRY_CAST`. Only a type that a +`VARIANT` kind holds without loss is supported, and any other type, such as `INTERVAL`, `RAW`, or +`BITMAP`, is rejected at validation. The value keeps the kind of its SQL type: + +| Input type | Stored `VARIANT` kind | +|--------------------------------------------|-------------------------------------------------| +| `BOOLEAN` | `BOOLEAN` | +| `TINYINT`, `SMALLINT`, `INTEGER`, `BIGINT` | `TINYINT`, `SMALLINT`, `INT`, `BIGINT` | +| `FLOAT`, `DOUBLE`, `DECIMAL` | `FLOAT`, `DOUBLE`, `DECIMAL` | +| `CHAR`, `VARCHAR`, `STRING` | `STRING` | +| `BINARY`, `VARBINARY`, `BYTES` | `BYTES` | +| `DATE`, `TIME`, `UUID` | `DATE`, `TIME`, `UUID` | +| `TIMESTAMP`, `TIMESTAMP_LTZ` | `TIMESTAMP`, `TIMESTAMP_LTZ` | + +- An integer keeps the width of its SQL type, so a `BIGINT` is stored as a `BIGINT` even when the + value would fit a smaller kind. `PARSE_JSON('1')` instead picks the smallest kind, a `TINYINT`. + Either way it casts back to any integer type that holds the value. +- A character string is stored as a `STRING` and is never parsed. Use `PARSE_JSON` to parse JSON text. +- A `TIMESTAMP(p)` or `TIMESTAMP_LTZ(p)` keeps its declared precision. Up to a precision of 6 it is + stored with microseconds, and above with nanoseconds, even when the value has no digits below a + microsecond. Nanoseconds only cover 1677-09-21 to 2262-04-11, so for a precision above 6 a value + outside that range fails the cast, and `TRY_CAST` returns `NULL`. +- A `NaN` or infinite `FLOAT` or `DOUBLE` is stored as is, although `PARSE_JSON` rejects them. A + `VARIANT` is not limited to JSON, but JSON has no literal for them. So `JSON_STRING` fails on such + a variant, and so does writing it to a sink with the `json` or `raw` format, which fails the job. + Printing it and casting it to `STRING` show `NaN`, and it casts back to `FLOAT` or `DOUBLE` + unchanged. +- A `VARIANT` holds at most 16 MiB, so a string or binary value can have at most 16,777,211 bytes. + A longer value fails the cast, and `TRY_CAST` returns `NULL`. +- A SQL `NULL` casts to a SQL `NULL`, not to a variant null. +- The cast is never applied implicitly, so writing an `INT` into a `VARIANT` column needs an explicit + `CAST`. +- A constructed type casts element by element when its element, field, or value type casts to + `VARIANT`, for example `ARRAY<INT>` to `ARRAY<VARIANT>`. A `NULL` element stays a SQL `NULL`. A + whole `ARRAY`, `MAP`, or `ROW` does not cast into a single `VARIANT` yet. +- Two `VARIANT` values are equal only when their binary encodings match. So as a `MAP` key or a + `MULTISET` element, a `1` cast from `INT` does not match a `1` cast from `BIGINT` or parsed by + `PARSE_JSON('1')`. + +```sql +CAST(42 AS VARIANT) -- 42, stored as an INT +CAST(CAST(1 AS BIGINT) AS VARIANT) -- 1, stored as a BIGINT +CAST('{"a": 1}' AS VARIANT) -- the string '{"a": 1}', not an object +CAST(NULL AS VARIANT) -- NULL +CAST(CAST('NaN' AS DOUBLE) AS VARIANT) -- NaN, stored as a DOUBLE +CAST(INTERVAL '2' DAY AS VARIANT) -- fails at validation +CAST(ARRAY[1, NULL] AS ARRAY<VARIANT>) -- [1, NULL], each element a VARIANT, the NULL stays SQL NULL +CAST(ARRAY[1, 2] AS VARIANT) -- fails at validation, not supported yet +``` + **Declaration** {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}} @@ -1874,20 +1925,20 @@ The matrix below describes the supported cast pairs, where "Y" means supported, | Input\Target | `CHAR`¹/<br/>`VARCHAR`¹/<br/>`STRING` | `BINARY`¹/<br/>`VARBINARY`¹/<br/>`BYTES` | `BOOLEAN` | `DECIMAL` | `TINYINT` | `SMALLINT` | `INTEGER` | `BIGINT` | `FLOAT` | `DOUBLE` | `DATE` | `TIME` | `TIMESTAMP` | `TIMESTAMP_LTZ` | `INTERVAL` | `ARRAY` | `MULTISET` | `MAP` | `ROW` | `STRUCTURED` | `RAW` | `VARIANT` | `BITMAP` | |:---------------------------------------|:-------------------------------------:|:----------------------------------------:|:---------:|:---------:|:---------:|:----------:|:---------:|:--------:|:-------:|:--------:|:------:|:------:|:-----------:|:---------------:|:----------:|:-------:|:----------:|:-----:|:-----:|:------------:|:-----:|:---------:|:--------:| -| `CHAR`/<br/>`VARCHAR`/<br/>`STRING` | Y | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | N | N | N | N | N | N | N | N | N | -| `BINARY`/<br/>`VARBINARY`/<br/>`BYTES` | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `BOOLEAN` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DECIMAL` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `TINYINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | N | N | -| `SMALLINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | N | N | -| `INTEGER` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁵ | N | N | N | N | N | N | N | N | -| `BIGINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁶ | N | N | N | N | N | N | N | N | -| `FLOAT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DOUBLE` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | -| `DATE` | Y | N | N | N | N | N | N | N | N | N | Y | N | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIME` | Y | N | N | N | N | N | N | N | N | N | N | Y | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIMESTAMP` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | -| `TIMESTAMP_LTZ` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | +| `CHAR`/<br/>`VARCHAR`/<br/>`STRING` | Y | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | ! | N | N | N | N | N | N | N | ! | N | +| `BINARY`/<br/>`VARBINARY`/<br/>`BYTES` | Y | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | ! | N | +| `BOOLEAN` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DECIMAL` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `TINYINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | Y | N | +| `SMALLINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | N | N | N | N | N | N | N | Y | N | +| `INTEGER` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁵ | N | N | N | N | N | N | Y | N | +| `BIGINT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N² | N² | Y⁶ | N | N | N | N | N | N | Y | N | +| `FLOAT` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DOUBLE` | Y | N | Y | Y | Y | Y | Y | Y | Y | Y | N | N | N | N | N | N | N | N | N | N | N | Y | N | +| `DATE` | Y | N | N | N | N | N | N | N | N | N | Y | N | Y | Y | N | N | N | N | N | N | N | Y | N | +| `TIME` | Y | N | N | N | N | N | N | N | N | N | N | Y | Y | Y | N | N | N | N | N | N | N | Y | N | +| `TIMESTAMP` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | ! | N | +| `TIMESTAMP_LTZ` | Y | N | N | N | N | N | N | N | N | N | Y | Y | Y | Y | N | N | N | N | N | N | N | ! | N | | `INTERVAL` | Y | N | N | N | N | N | Y⁵ | Y⁶ | N | N | N | N | N | N | Y | N | N | N | N | N | N | N | N | | `ARRAY` | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | !³ | N | N | N | N | N | N | N | | `MULTISET` | Y | N | N | N | N | N | N | N | N | N | N | N | N | N | N | N | !³ | N | N | N | N | N | N | 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 d351a36746d..ab0036b5a70 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 @@ -403,6 +403,33 @@ public final class LogicalTypeCasts { .explicitFromFamily(EXACT_NUMERIC, CHARACTER_STRING) .build(); + // ----------------------------------------------------------------------------------------- + // VARIANT type + // ----------------------------------------------------------------------------------------- + + // Only a type with a VARIANT kind that holds its value without loss casts to VARIANT. + castTo(VARIANT) + .implicitFrom(VARIANT) + .explicitFrom( + BOOLEAN, + TINYINT, + SMALLINT, + INTEGER, + BIGINT, + FLOAT, + DOUBLE, + DECIMAL, + CHAR, + VARCHAR, + BINARY, + VARBINARY, + DATE, + TIME_WITHOUT_TIME_ZONE, + TIMESTAMP_WITHOUT_TIME_ZONE, + TIMESTAMP_WITH_LOCAL_TIME_ZONE, + UUID) + .build(); + // ----------------------------------------------------------------------------------------- // UUID type // ----------------------------------------------------------------------------------------- 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 1e96b65a130..a238417654b 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 @@ -390,8 +390,73 @@ class LogicalTypeCastsTest { // numeric is not castable to or from UUID Arguments.of(new UuidType(), new IntType(), false, false), Arguments.of(new IntType(), new UuidType(), false, false), - // a UUID does not cast to VARIANT - Arguments.of(new UuidType(), new VariantType(), false, false)); + // a type with a VARIANT kind casts to VARIANT, explicitly only + Arguments.of(new BooleanType(), new VariantType(), false, true), + Arguments.of(new TinyIntType(), new VariantType(), false, true), + Arguments.of(new SmallIntType(), new VariantType(), false, true), + Arguments.of(new IntType(), new VariantType(), false, true), + Arguments.of(new BigIntType(), new VariantType(), false, true), + Arguments.of(new FloatType(), new VariantType(), false, true), + Arguments.of(new DoubleType(), new VariantType(), false, true), + Arguments.of(new DecimalType(38, 10), new VariantType(), false, true), + Arguments.of(new CharType(5), new VariantType(), false, true), + Arguments.of(VarCharType.STRING_TYPE, new VariantType(), false, true), + Arguments.of(new BinaryType(4), new VariantType(), false, true), + Arguments.of( + new VarBinaryType(VarBinaryType.MAX_LENGTH), + new VariantType(), + false, + true), + Arguments.of(new DateType(), new VariantType(), false, true), + Arguments.of(new TimeType(), new VariantType(), false, true), + Arguments.of(new TimestampType(9), new VariantType(), false, true), + Arguments.of(new LocalZonedTimestampType(3), new VariantType(), false, true), + Arguments.of(new UuidType(), new VariantType(), false, true), + Arguments.of(new NullType(), new VariantType(), true, true), + // a type without a VARIANT kind does not cast to VARIANT + Arguments.of( + new YearMonthIntervalType(YearMonthIntervalType.YearMonthResolution.MONTH), + new VariantType(), + false, + false), + Arguments.of(new ZonedTimestampType(), new VariantType(), false, false), + Arguments.of( + new MultisetType(VarCharType.STRING_TYPE), new VariantType(), false, false), + // a constructed type casts element by element when each child casts to VARIANT + Arguments.of( + new ArrayType(new IntType()), + new ArrayType(new VariantType()), + false, + true), + Arguments.of( + new RowType(List.of(new RowField("a", new IntType()))), + new RowType(List.of(new RowField("a", new VariantType()))), + false, + true), + Arguments.of( + new MapType(VarCharType.STRING_TYPE, new IntType()), + new MapType(VarCharType.STRING_TYPE, new VariantType()), + false, + true), + Arguments.of( + new ArrayType( + new YearMonthIntervalType( + YearMonthIntervalType.YearMonthResolution.MONTH)), + new ArrayType(new VariantType()), + false, + false), + // a whole constructed value does not cast into a single VARIANT yet + Arguments.of(new ArrayType(new IntType()), new VariantType(), false, false), + Arguments.of( + new MapType(VarCharType.STRING_TYPE, new IntType()), + new VariantType(), + false, + false), + Arguments.of( + new RowType(List.of(new RowField("a", new IntType()))), + new VariantType(), + false, + false)); } @ParameterizedTest(name = "{index}: [From: {0}, To: {1}, Implicit: {2}, Explicit: {3}]") diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java index bd5ba1067a8..b1cfeaa1142 100644 --- a/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java +++ b/flink-table/flink-table-planner/src/main/java/org/apache/calcite/sql/fun/SqlCastFunction.java @@ -304,18 +304,16 @@ public class SqlCastFunction extends SqlFunction { SqlTypeName fromTypeName = fromType.getSqlTypeName(); SqlTypeName toTypeName = toType.getSqlTypeName(); - // Cast to Variant is not support at the moment. - // TODO: Support cast to variant (FLINK-37925,FLINK-37926) - if (toTypeName == SqlTypeName.VARIANT) { - return false; - } // Cast to BITMAP is not supported at the moment. if (toType instanceof BitmapRelDataType) { return false; } - // UUID casts are governed entirely by our own checker in both directions, because Calcite - // only allows a UUID to be cast from another UUID. - if (toTypeName == SqlTypeName.UUID || fromTypeName == SqlTypeName.UUID) { + // UUID and casts to VARIANT are governed entirely by our own checker, because Calcite + // only allows a UUID to be cast from another UUID and does not know which types VARIANT + // can store. + if (toTypeName == SqlTypeName.UUID + || fromTypeName == SqlTypeName.UUID + || toTypeName == SqlTypeName.VARIANT) { return LogicalTypeCasts.supportsExplicitCast( FlinkTypeFactory.toLogicalType(fromType), FlinkTypeFactory.toLogicalType(toType)); diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java index 532c9bca994..e3e07fd0941 100644 --- a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java +++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java @@ -101,6 +101,7 @@ public class CastRuleProvider { .addRule(VariantToArrayCastRule.INSTANCE) .addRule(VariantToRowCastRule.INSTANCE) .addRule(VariantToMapCastRule.INSTANCE) + .addRule(PrimitiveToVariantCastRule.INSTANCE) // Bitmap rules .addRule(BitmapToStringCastRule.INSTANCE) .addRule(BitmapToBinaryCastRule.INSTANCE) diff --git a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/PrimitiveToVariantCastRule.java b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/PrimitiveToVariantCastRule.java new file mode 100644 index 00000000000..12c32e7324b --- /dev/null +++ b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/PrimitiveToVariantCastRule.java @@ -0,0 +1,179 @@ +/* + * 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.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.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.staticCall; + +/** + * Cast rule from a primitive type to {@link LogicalTypeRoot#VARIANT}. + * + * <p>The value keeps the kind of its SQL type. For example, a {@code BIGINT} is stored as a {@code + * BIGINT}, even when it would fit a smaller kind. A {@code NaN} or infinite {@code FLOAT} or {@code + * DOUBLE} is stored as is, although {@code PARSE_JSON} rejects it. Some types can fail for some + * values, see {@link #canFail}. + */ +class PrimitiveToVariantCastRule extends AbstractExpressionCodeGeneratorCastRule<Object, Variant> { + + static final PrimitiveToVariantCastRule INSTANCE = new PrimitiveToVariantCastRule(); + + /** A character takes up to 4 bytes in UTF-8, which a declared length counts as one. */ + private static final int MAX_UTF8_BYTES_PER_CHAR = 4; + + private PrimitiveToVariantCastRule() { + super( + CastRulePredicate.builder() + .predicate( + (input, target) -> + target.is(LogicalTypeRoot.VARIANT) + && isSupportedSource(input)) + .build()); + } + + private static boolean isSupportedSource(LogicalType inputType) { + switch (inputType.getTypeRoot()) { + case BOOLEAN: + case TINYINT: + case SMALLINT: + case INTEGER: + case BIGINT: + case FLOAT: + case DOUBLE: + case DECIMAL: + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + case DATE: + case TIME_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + case UUID: + return true; + default: + return false; + } + } + + /** + * Returns whether a value of the input type can fail the cast, so that {@code TRY_CAST} returns + * {@code NULL} for it instead of failing. Two kinds of types can fail: + * + * <ul> + * <li>A {@code TIMESTAMP(p)} or {@code TIMESTAMP_LTZ(p)} with {@code p > 6} is stored with + * nanoseconds, which only cover 1677-09-21 to 2262-04-11. + * <li>A string or binary value over {@link VariantCastUtils#MAX_PAYLOAD_BYTES} does not fit + * into a {@code VARIANT}. Only a type whose declared length allows such a value can fail. + * </ul> + * + * <p>Every other type never fails. + * + * <p>The length check trusts the declared length. Flink does not enforce that length on values + * from a source, so a longer value can still reach the cast. {@code TRY_CAST} then fails for it + * instead of returning {@code NULL}. + */ + @Override + public boolean canFail(LogicalType inputLogicalType, LogicalType targetLogicalType) { + switch (inputLogicalType.getTypeRoot()) { + case TIMESTAMP_WITHOUT_TIME_ZONE: + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return LogicalTypeChecks.getPrecision(inputLogicalType) + > VariantCastUtils.TIMESTAMP_PRECISION; + case CHAR: + case VARCHAR: + return (long) MAX_UTF8_BYTES_PER_CHAR + * LogicalTypeChecks.getLength(inputLogicalType) + > VariantCastUtils.MAX_PAYLOAD_BYTES; + case BINARY: + case VARBINARY: + return LogicalTypeChecks.getLength(inputLogicalType) + > VariantCastUtils.MAX_PAYLOAD_BYTES; + default: + return false; + } + } + + /* Example generated code for INT: + + result$0 = org.apache.flink.table.runtime.functions.VariantCastUtils.fromIntegral(int$0); + + */ + @Override + public String generateExpression( + CodeGeneratorCastRule.Context context, + String inputTerm, + LogicalType inputLogicalType, + LogicalType targetLogicalType) { + if (isTimestamp(inputLogicalType)) { + return staticCall( + VariantCastUtils.class, + helperName(inputLogicalType), + inputTerm, + LogicalTypeChecks.getPrecision(inputLogicalType)); + } + return staticCall(VariantCastUtils.class, helperName(inputLogicalType), inputTerm); + } + + private static boolean isTimestamp(LogicalType inputLogicalType) { + return inputLogicalType.is(LogicalTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE) + || inputLogicalType.is(LogicalTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE); + } + + private static String helperName(LogicalType inputLogicalType) { + switch (inputLogicalType.getTypeRoot()) { + case BOOLEAN: + return "fromBoolean"; + case TINYINT: + case SMALLINT: + case INTEGER: + case BIGINT: + return "fromIntegral"; + case FLOAT: + return "fromFloat"; + case DOUBLE: + return "fromDouble"; + case DECIMAL: + return "fromDecimal"; + case CHAR: + case VARCHAR: + return "fromString"; + case BINARY: + case VARBINARY: + return "fromBytes"; + case DATE: + return "fromDate"; + case TIME_WITHOUT_TIME_ZONE: + return "fromTime"; + case TIMESTAMP_WITHOUT_TIME_ZONE: + return "fromTimestamp"; + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + return "fromTimestampLtz"; + case UUID: + return "fromUuid"; + default: + throw new IllegalArgumentException( + "Unsupported source type for casting to VARIANT: " + inputLogicalType); + } + } +} 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 e67bc261696..d7f4dc4a2a0 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 @@ -31,6 +31,8 @@ import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.flink.table.types.logical.utils.LogicalTypeCasts; import org.apache.flink.types.Row; import org.apache.flink.types.bitmap.Bitmap; +import org.apache.flink.types.variant.Variant; +import org.apache.flink.types.variant.VariantBuilder; import java.math.BigDecimal; import java.nio.ByteBuffer; @@ -160,6 +162,7 @@ public class CastFunctionITCase extends BuiltInFunctionTestBase { specs.addAll(variantArrayCasts()); specs.addAll(variantRowCasts()); specs.addAll(variantMapCasts()); + specs.addAll(primitiveToVariantCasts()); return specs; } @@ -432,6 +435,280 @@ public class CastFunctionITCase extends BuiltInFunctionTestBase { TINYINT())); } + private static List<TestSetSpec> primitiveToVariantCasts() { + final VariantBuilder builder = Variant.newBuilder(); + final LocalDateTime nanos = LocalDateTime.parse("2026-09-25T10:15:30.123456789"); + final Instant instant = Instant.parse("2026-09-25T10:15:30.123Z"); + return List.of( + // A value keeps the kind of its SQL type, so an integer keeps its width, and a + // string is wrapped rather than parsed. + CastTestSpecBuilder.testCastTo(VARIANT()) + .fromCase(BOOLEAN(), true, builder.of(true)) + .fromCase(INT(), 42, builder.of(42)) + .fromCase(BIGINT(), 1L, builder.of(1L)) + .fromCase(BIGINT(), 10000000000L, builder.of(10000000000L)) + .fromCase(DOUBLE(), 1.5d, builder.of(1.5d)) + .fromCase( + DECIMAL(4, 2), + new BigDecimal("12.50"), + builder.of(new BigDecimal("12.50"))) + .fromCase(STRING(), "{\"a\":1}", builder.of("{\"a\":1}")) + .fromCase( + DATE(), + LocalDate.parse("2026-09-25"), + builder.of(LocalDate.parse("2026-09-25"))) + .fromCase(TIMESTAMP(9), nanos, builder.of(nanos)) + .fromCase(TIMESTAMP_LTZ(3), instant, builder.of(instant)) + .fromCase(UUID(), DEFAULT_UUID, builder.of(DEFAULT_UUID)) + .fromCase(INT(), null, null) + // a type without a VARIANT kind is rejected at validation + .failValidation(INTERVAL(MONTH()), Period.ofMonths(2)) + .build(), + TestSetSpec.forExpression("Cast a primitive to VARIANT and back") + .onFieldsWithData( + 42L, + new BigDecimal("12.50"), + "hello", + LocalTime.of(10, 15, 30), + nanos, + instant, + DEFAULT_UUID, + new byte[] {1, 2, 3}) + .andDataTypes( + BIGINT(), + DECIMAL(4, 2), + STRING(), + TIME(), + TIMESTAMP(9), + TIMESTAMP_LTZ(3), + UUID(), + BYTES()) + .testResult( + $("f0").cast(VARIANT()).cast(BIGINT()), + "CAST(CAST(f0 AS VARIANT) AS BIGINT)", + 42L, + BIGINT()) + .testResult( + $("f1").cast(VARIANT()).cast(DECIMAL(4, 2)), + "CAST(CAST(f1 AS VARIANT) AS DECIMAL(4, 2))", + new BigDecimal("12.50"), + DECIMAL(4, 2)) + .testResult( + $("f2").cast(VARIANT()).cast(STRING()), + "CAST(CAST(f2 AS VARIANT) AS STRING)", + "hello", + STRING()) + .testResult( + $("f3").cast(VARIANT()).cast(TIME()), + "CAST(CAST(f3 AS VARIANT) AS TIME)", + LocalTime.of(10, 15, 30), + TIME()) + .testResult( + $("f4").cast(VARIANT()).cast(TIMESTAMP(9)), + "CAST(CAST(f4 AS VARIANT) AS TIMESTAMP(9))", + nanos, + TIMESTAMP(9)) + .testResult( + $("f5").cast(VARIANT()).cast(TIMESTAMP_LTZ(3)), + "CAST(CAST(f5 AS VARIANT) AS TIMESTAMP_LTZ(3))", + instant, + TIMESTAMP_LTZ(3)) + .testResult( + $("f6").cast(VARIANT()).cast(UUID()), + "CAST(CAST(f6 AS VARIANT) AS UUID)", + DEFAULT_UUID, + UUID()) + .testResult( + $("f7").cast(VARIANT()).cast(BYTES()), + "CAST(CAST(f7 AS VARIANT) AS BYTES)", + new byte[] {1, 2, 3}, + BYTES()), + TestSetSpec.forExpression("Cast TIME to VARIANT and back") + .onFieldsWithData( + LocalTime.of(12, 34, 56, 123_000_000), + LocalTime.MIDNIGHT, + LocalTime.of(23, 59, 59, 999_000_000)) + .andDataTypes(TIME(3), TIME(0), TIME(3)) + .testResult( + $("f0").cast(VARIANT()).cast(TIME(3)), + "CAST(CAST(f0 AS VARIANT) AS TIME(3))", + LocalTime.of(12, 34, 56, 123_000_000), + TIME(3)) + .testResult( + $("f1").cast(VARIANT()).cast(TIME(0)), + "CAST(CAST(f1 AS VARIANT) AS TIME(0))", + LocalTime.MIDNIGHT, + TIME(0)) + .testResult( + $("f2").cast(VARIANT()).cast(TIME(3)), + "CAST(CAST(f2 AS VARIANT) AS TIME(3))", + LocalTime.of(23, 59, 59, 999_000_000), + TIME(3)), + // a VARIANT holds at most 16 MiB, so TRY_CAST returns NULL for a longer string + TestSetSpec.forExpression("Cast a string over the size limit to VARIANT") + .onFieldsWithData("x") + .andDataTypes(STRING()) + .testSqlResult( + "TRY_CAST(REPEAT(f0, 17000000) AS VARIANT)", null, VARIANT()), + // nanoseconds only cover 1677-09-21 to 2262-04-11, while microseconds hold any year + TestSetSpec.forExpression("Cast a late TIMESTAMP to VARIANT") + .onFieldsWithData( + LocalDateTime.parse("3000-01-01T00:00"), + LocalDateTime.parse("3000-01-01T00:00")) + .andDataTypes(TIMESTAMP(9), TIMESTAMP(6)) + .testSqlRuntimeError( + "CAST(f0 AS VARIANT)", + TableRuntimeException.class, + "1677-09-21 to 2262-04-11") + .testTableApiRuntimeError( + $("f0").cast(VARIANT()), + TableRuntimeException.class, + "1677-09-21 to 2262-04-11") + .testResult( + $("f0").tryCast(VARIANT()), + "TRY_CAST(f0 AS VARIANT)", + null, + VARIANT()) + .testResult( + $("f1").cast(VARIANT()).cast(TIMESTAMP(6)), + "CAST(CAST(f1 AS VARIANT) AS TIMESTAMP(6))", + LocalDateTime.parse("3000-01-01T00:00"), + TIMESTAMP(6)), + TestSetSpec.forExpression("Cast a literal to VARIANT") + .onFieldsWithData(0) + .testResult( + lit(42).cast(VARIANT()), + "CAST(42 AS VARIANT)", + builder.of(42), + VARIANT().notNull()) + .testResult( + lit("hello").cast(VARIANT()), + "CAST('hello' AS VARIANT)", + builder.of("hello"), + VARIANT().notNull()) + // a NULL literal casts to a SQL NULL, not to a variant null + .testSqlResult("CAST(NULL AS VARIANT)", null, VARIANT()) + .testSqlResult("TRY_CAST(NULL AS VARIANT)", null, VARIANT()), + // a VARIANT is not limited to JSON, so NaN and infinity are kept and round-trip + TestSetSpec.forExpression("Cast a NaN literal to VARIANT and back") + .onFieldsWithData(0) + .testResult( + lit("NaN").cast(DOUBLE()).cast(VARIANT()).cast(DOUBLE()), + "CAST(CAST(CAST('NaN' AS DOUBLE) AS VARIANT) AS DOUBLE)", + Double.NaN, + DOUBLE()) + .testResult( + lit("NaN").cast(DOUBLE()).cast(VARIANT()).cast(STRING()), + "CAST(CAST(CAST('NaN' AS DOUBLE) AS VARIANT) AS STRING)", + "NaN", + STRING()), + TestSetSpec.forExpression("Cast a non-finite number to VARIANT and back") + // the non-finite values are produced at runtime from strings + .onFieldsWithData("NaN", "Infinity", "-Infinity") + .andDataTypes(STRING(), STRING(), STRING()) + .testResult( + $("f0").cast(DOUBLE()).cast(VARIANT()).cast(DOUBLE()), + "CAST(CAST(CAST(f0 AS DOUBLE) AS VARIANT) AS DOUBLE)", + Double.NaN, + DOUBLE()) + .testResult( + $("f1").cast(FLOAT()).cast(VARIANT()).cast(FLOAT()), + "CAST(CAST(CAST(f1 AS FLOAT) AS VARIANT) AS FLOAT)", + Float.POSITIVE_INFINITY, + FLOAT()) + .testResult( + $("f2").cast(DOUBLE()).cast(VARIANT()).cast(FLOAT()), + "CAST(CAST(CAST(f2 AS DOUBLE) AS VARIANT) AS FLOAT)", + Float.NEGATIVE_INFINITY, + FLOAT()) + // an integer cannot hold NaN, so that cast still fails + .testSqlRuntimeError( + "CAST(CAST(CAST(f0 AS DOUBLE) AS VARIANT) AS INT)", + TableRuntimeException.class, + "overflowed"), + TestSetSpec.forExpression("Cast narrow and fixed-width types to VARIANT and back") + .onFieldsWithData((byte) 42, (short) 1000) + .andDataTypes(TINYINT(), SMALLINT()) + .testResult( + $("f0").cast(VARIANT()).cast(TINYINT()), + "CAST(CAST(f0 AS VARIANT) AS TINYINT)", + (byte) 42, + TINYINT()) + .testResult( + $("f1").cast(VARIANT()).cast(SMALLINT()), + "CAST(CAST(f1 AS VARIANT) AS SMALLINT)", + (short) 1000, + SMALLINT()) + // a CHAR(n) is stored with its padding, so the spaces survive + .testResult( + lit("ab").cast(CHAR(4)).cast(VARIANT()).cast(STRING()), + "CAST(CAST(CAST('ab' AS CHAR(4)) AS VARIANT) AS STRING)", + "ab ", + STRING()) + .testResult( + lit("ab").cast(CHAR(4)).cast(VARIANT()).cast(CHAR(4)), + "CAST(CAST(CAST('ab' AS CHAR(4)) AS VARIANT) AS CHAR(4))", + "ab ", + CHAR(4)) + // a BINARY(n) is stored with its zero padding + .testResult( + lit(new byte[] {1, 2}) + .cast(BINARY(4)) + .cast(VARIANT()) + .cast(BYTES()), + "CAST(CAST(CAST(X'0102' AS BINARY(4)) AS VARIANT) AS BYTES)", + new byte[] {1, 2, 0, 0}, + BYTES()), + // a constructed type casts element by element when each child casts to VARIANT + TestSetSpec.forExpression("Cast a constructed type to VARIANT elements and back") + .onFieldsWithData( + new Integer[] {1, null}, + Row.of(7), + map(entry("a", 1)), + map(entry(1, "a"))) + .andDataTypes( + ARRAY(INT()), + ROW(FIELD("a", INT())), + MAP(STRING(), INT()), + MAP(INT(), STRING())) + .testResult( + $("f0").cast(ARRAY(VARIANT())).cast(ARRAY(INT())), + "CAST(CAST(f0 AS ARRAY<VARIANT>) AS ARRAY<INT>)", + new Integer[] {1, null}, + ARRAY(INT())) + .testResult( + $("f1").cast(ROW(FIELD("a", VARIANT()))) + .cast(ROW(FIELD("a", INT()))), + "CAST(CAST(f1 AS ROW<a VARIANT>) AS ROW<a INT>)", + Row.of(7), + ROW(FIELD("a", INT()))) + .testResult( + $("f2").cast(MAP(STRING(), VARIANT())).cast(MAP(STRING(), INT())), + "CAST(CAST(f2 AS MAP<STRING, VARIANT>) AS MAP<STRING, INT>)", + map(entry("a", 1)), + MAP(STRING(), INT())) + // a VARIANT key matches by its bytes, so a key cast from INT does not + // match the same number cast from BIGINT + .testResult( + $("f3").cast(MAP(VARIANT(), STRING())).at(lit(1).cast(VARIANT())), + "CAST(f3 AS MAP<VARIANT, STRING>)[CAST(1 AS VARIANT)]", + "a", + STRING()) + .testResult( + $("f3").cast(MAP(VARIANT(), STRING())).at(lit(1L).cast(VARIANT())), + "CAST(f3 AS MAP<VARIANT, STRING>)[CAST(CAST(1 AS BIGINT) AS VARIANT)]", + null, + STRING()), + // a whole constructed value does not cast into a single VARIANT yet + TestSetSpec.forExpression("Cast a constructed type to VARIANT") + .onFieldsWithData(0) + .testSqlValidationError( + "CAST(ARRAY[1, 2] AS VARIANT)", + "Cast function cannot convert value") + .testTableApiValidationError( + lit(new int[] {1, 2}).cast(VARIANT()), "Unsupported cast")); + } + private static List<TestSetSpec> variantArrayCasts() { return List.of( TestSetSpec.forExpression("Cast a VARIANT produced by parseJson() to an ARRAY") diff --git a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java index df910dfe1c4..6849d79e6ad 100644 --- a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java +++ b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java @@ -32,9 +32,12 @@ import static org.apache.flink.table.api.DataTypes.ARRAY; import static org.apache.flink.table.api.DataTypes.BIGINT; import static org.apache.flink.table.api.DataTypes.BOOLEAN; import static org.apache.flink.table.api.DataTypes.BYTES; +import static org.apache.flink.table.api.DataTypes.CHAR; import static org.apache.flink.table.api.DataTypes.DATE; import static org.apache.flink.table.api.DataTypes.DECIMAL; +import static org.apache.flink.table.api.DataTypes.DOUBLE; import static org.apache.flink.table.api.DataTypes.FIELD; +import static org.apache.flink.table.api.DataTypes.FLOAT; import static org.apache.flink.table.api.DataTypes.INT; import static org.apache.flink.table.api.DataTypes.INTERVAL; import static org.apache.flink.table.api.DataTypes.MAP; @@ -48,6 +51,8 @@ import static org.apache.flink.table.api.DataTypes.TIMESTAMP; import static org.apache.flink.table.api.DataTypes.TIMESTAMP_LTZ; import static org.apache.flink.table.api.DataTypes.TINYINT; import static org.apache.flink.table.api.DataTypes.UUID; +import static org.apache.flink.table.api.DataTypes.VARBINARY; +import static org.apache.flink.table.api.DataTypes.VARCHAR; import static org.apache.flink.table.api.DataTypes.VARIANT; import static org.apache.flink.table.types.logical.VarCharType.STRING_TYPE; import static org.assertj.core.api.Assertions.assertThat; @@ -147,6 +152,74 @@ class CastRuleProviderTest { .isSameAs(VariantToStringCastRule.INSTANCE); } + @Test + void testResolvePrimitiveToVariant() { + assertThat(CastRuleProvider.resolve(INT, VARIANT)) + .isSameAs(PrimitiveToVariantCastRule.INSTANCE); + assertThat(CastRuleProvider.resolve(STRING_TYPE, VARIANT)) + .isSameAs(PrimitiveToVariantCastRule.INSTANCE); + assertThat(CastRuleProvider.exists(DECIMAL(10, 2).getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.exists(TIMESTAMP(9).getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.exists(TIMESTAMP_LTZ().getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.exists(TIME().getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.exists(BYTES().getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.exists(UUID().getLogicalType(), VARIANT)).isTrue(); + + // only a nanosecond timestamp can fail, since that kind covers a limited range of years + assertThat(CastRuleProvider.canFail(INT, VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(DOUBLE().getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(FLOAT().getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(TIMESTAMP(6).getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(TIMESTAMP(9).getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.canFail(TIMESTAMP_LTZ(3).getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(TIMESTAMP_LTZ(7).getLogicalType(), VARIANT)).isTrue(); + + // a string or binary type fails only when its declared length allows a value over 16 MiB, + // and a character takes up to 4 bytes in UTF-8 + assertThat(CastRuleProvider.canFail(STRING_TYPE, VARIANT)).isTrue(); + assertThat(CastRuleProvider.canFail(BYTES().getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.canFail(VARCHAR(100).getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(CHAR(100).getLogicalType(), VARIANT)).isFalse(); + assertThat(CastRuleProvider.canFail(VARCHAR(4_194_302).getLogicalType(), VARIANT)) + .isFalse(); + assertThat(CastRuleProvider.canFail(VARCHAR(4_194_303).getLogicalType(), VARIANT)).isTrue(); + assertThat(CastRuleProvider.canFail(VARBINARY(16_777_211).getLogicalType(), VARIANT)) + .isFalse(); + assertThat(CastRuleProvider.canFail(VARBINARY(16_777_212).getLogicalType(), VARIANT)) + .isTrue(); + + // INTERVAL has no VARIANT kind, so it is not a castable source + assertThat(CastRuleProvider.exists(INTERVAL(DataTypes.DAY()).getLogicalType(), VARIANT)) + .isFalse(); + // VARIANT to VARIANT stays the identity + assertThat(CastRuleProvider.resolve(VARIANT, VARIANT)).isSameAs(IdentityCastRule.INSTANCE); + + // a constructed type casts element by element, through the rules for its children + assertThat( + CastRuleProvider.resolve( + ARRAY(INT()).getLogicalType(), ARRAY(VARIANT()).getLogicalType())) + .isSameAs(ArrayToArrayCastRule.INSTANCE); + assertThat( + CastRuleProvider.resolve( + ROW(FIELD("a", INT())).getLogicalType(), + ROW(FIELD("a", VARIANT())).getLogicalType())) + .isSameAs(RowToRowCastRule.INSTANCE); + assertThat( + CastRuleProvider.resolve( + MAP(STRING(), INT()).getLogicalType(), + MAP(STRING(), VARIANT()).getLogicalType())) + .isSameAs(MapToMapAndMultisetToMultisetCastRule.INSTANCE); + assertThat( + CastRuleProvider.canFail( + ARRAY(INT()).getLogicalType(), ARRAY(VARIANT()).getLogicalType())) + .isFalse(); + assertThat( + CastRuleProvider.canFail( + ARRAY(TIMESTAMP(9)).getLogicalType(), + ARRAY(VARIANT()).getLogicalType())) + .isTrue(); + } + @Test void testResolveVariantToArray() { assertThat(CastRuleProvider.resolve(VARIANT, ARRAY(INT()).getLogicalType())) 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 cfe2b641bfd..33015cef9e1 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 @@ -42,6 +42,7 @@ import org.apache.flink.table.types.logical.StructuredType; import org.apache.flink.table.utils.DateTimeUtils; import org.apache.flink.types.bitmap.Bitmap; import org.apache.flink.types.variant.BinaryVariant; +import org.apache.flink.types.variant.BinaryVariantInternalBuilder; import org.apache.flink.types.variant.Variant; import org.apache.flink.types.variant.VariantBuilder; @@ -60,6 +61,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; @@ -1946,6 +1949,165 @@ class CastRulesTest { VARIANT_BUILDER.of(NON_BMP + NON_BMP + NON_BMP), fromString(NON_BMP + NON_BMP)) .fromCase(VARIANT(), VARIANT_BUILDER.of("a"), fromString("a")), + CastTestSpecBuilder.testCastTo(VARIANT()) + .fromCase(BOOLEAN(), true, VARIANT_BUILDER.of(true)) + // an integer keeps the width of its SQL type, even when a smaller one fits + .fromCase(TINYINT(), (byte) 42, VARIANT_BUILDER.of((byte) 42)) + .fromCase(SMALLINT(), (short) 42, VARIANT_BUILDER.of((short) 42)) + .fromCase(INT(), 42, VARIANT_BUILDER.of(42)) + .fromCase(BIGINT(), 1L, VARIANT_BUILDER.of(1L)) + .fromCase(BIGINT(), 42L, VARIANT_BUILDER.of(42L)) + .fromCase( + BIGINT(), + DEFAULT_NEGATIVE_BIGINT, + VARIANT_BUILDER.of(DEFAULT_NEGATIVE_BIGINT)) + .fromCase(BIGINT(), Long.MAX_VALUE, VARIANT_BUILDER.of(Long.MAX_VALUE)) + .fromCase(FLOAT(), 1.5f, VARIANT_BUILDER.of(1.5f)) + .fromCase(DOUBLE(), 1.5d, VARIANT_BUILDER.of(1.5d)) + .fromCase(DOUBLE(), Double.MAX_VALUE, VARIANT_BUILDER.of(Double.MAX_VALUE)) + // a VARIANT is not limited to JSON, so NaN and infinity are kept + .fromCase(FLOAT(), Float.NaN, VARIANT_BUILDER.of(Float.NaN)) + .fromCase( + FLOAT(), + Float.POSITIVE_INFINITY, + VARIANT_BUILDER.of(Float.POSITIVE_INFINITY)) + .fromCase(DOUBLE(), Double.NaN, VARIANT_BUILDER.of(Double.NaN)) + .fromCase( + DOUBLE(), + Double.NEGATIVE_INFINITY, + VARIANT_BUILDER.of(Double.NEGATIVE_INFINITY)) + // a decimal keeps its scale + .fromCase( + DECIMAL(4, 2), + fromBigDecimal(new BigDecimal("12.50"), 4, 2), + VARIANT_BUILDER.of(new BigDecimal("12.50"))) + .fromCase( + DECIMAL(38, 0), + fromBigDecimal(new BigDecimal("9".repeat(38)), 38, 0), + VARIANT_BUILDER.of(new BigDecimal("9".repeat(38)))) + // a string is wrapped, never parsed as JSON + .fromCase( + STRING(), fromString("{\"a\":1}"), VARIANT_BUILDER.of("{\"a\":1}")) + .fromCase(CHAR(4), fromString("ab "), VARIANT_BUILDER.of("ab ")) + .fromCase( + BYTES(), + new byte[] {1, 2, 3}, + VARIANT_BUILDER.of(new byte[] {1, 2, 3})) + .fromCase(DATE(), DATE, VARIANT_BUILDER.of(LocalDate.parse("2021-09-24"))) + // VARIANT stores TIME in microseconds, so every Flink TIME fits exactly, + // since the runtime keeps milliseconds + .fromCase(TIME(), TIME, VARIANT_BUILDER.of(LocalTime.parse("12:34:56.123"))) + .fromCase( + TIME(0), + DateTimeUtils.toInternal(LocalTime.of(12, 34, 56)), + VARIANT_BUILDER.of(LocalTime.of(12, 34, 56))) + .fromCase( + TIME(3), + DateTimeUtils.toInternal(LocalTime.of(12, 34, 56, 123_000_000)), + VARIANT_BUILDER.of(LocalTime.of(12, 34, 56, 123_000_000))) + .fromCase(TIME(0), 0, VARIANT_BUILDER.of(LocalTime.MIDNIGHT)) + .fromCase( + TIME(3), + DateTimeUtils.toInternal(LocalTime.of(23, 59, 59, 999_000_000)), + VARIANT_BUILDER.of(LocalTime.of(23, 59, 59, 999_000_000))) + // microsecond precision and below is stored as a TIMESTAMP + .fromCase( + TIMESTAMP(3), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("2021-09-24T12:34:56.123")), + VARIANT_BUILDER.of(LocalDateTime.parse("2021-09-24T12:34:56.123"))) + .fromCase( + TIMESTAMP(6), + TIMESTAMP, + VARIANT_BUILDER.of( + LocalDateTime.parse("2021-09-24T12:34:56.123456"))) + // micros before the epoch are negative, 1969-12-31T23:59:59.999999 is -1 + .fromCase( + TIMESTAMP(6), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("1969-12-31T23:59:59.999999")), + VARIANT_BUILDER.of( + LocalDateTime.parse("1969-12-31T23:59:59.999999"))) + .fromCase( + TIMESTAMP_LTZ(6), + TimestampData.fromInstant( + Instant.parse("1969-12-31T23:59:59.999999Z")), + VARIANT_BUILDER.of(Instant.parse("1969-12-31T23:59:59.999999Z"))) + // a microsecond precision holds any year, so a late date still casts + .fromCase( + TIMESTAMP(6), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("3000-01-01T00:00")), + VARIANT_BUILDER.of(LocalDateTime.parse("3000-01-01T00:00"))) + // a precision above 6 is stored with nanoseconds, even for a value that has + // no digits below a microsecond + .fromCase( + TIMESTAMP(9), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("2021-09-24T12:34:56.123456789")), + VARIANT_BUILDER.of( + LocalDateTime.parse("2021-09-24T12:34:56.123456789"))) + .fromCase( + TIMESTAMP(9), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("2021-09-24T12:34:56.123456")), + timestampNanosVariant( + LocalDateTime.parse("2021-09-24T12:34:56.123456"))) + .fromCase( + TIMESTAMP_LTZ(3), + TimestampData.fromInstant( + Instant.parse("2022-01-04T12:34:56.123Z")), + VARIANT_BUILDER.of(Instant.parse("2022-01-04T12:34:56.123Z"))) + .fromCase( + TIMESTAMP_LTZ(9), + TIMESTAMP_LTZ, + VARIANT_BUILDER.of(TIMESTAMP_LTZ.toInstant())) + .fromCase( + TIMESTAMP_LTZ(9), + TimestampData.fromInstant(Instant.parse("2022-01-04T12:34:56Z")), + timestampLtzNanosVariant(Instant.parse("2022-01-04T12:34:56Z"))) + // nanoseconds only cover 1677-09-21 to 2262-04-11 + .fail( + TIMESTAMP(9), + TimestampData.fromLocalDateTime( + LocalDateTime.parse("3000-01-01T00:00")), + TableRuntimeException.class, + "1677-09-21 to 2262-04-11") + .fail( + TIMESTAMP_LTZ(7), + TimestampData.fromInstant(Instant.parse("1500-01-01T00:00:00Z")), + TableRuntimeException.class, + "1677-09-21 to 2262-04-11") + .fromCase(UUID(), UUID_BYTES, VARIANT_BUILDER.of(UUID_VALUE)) + // a SQL NULL stays a SQL NULL rather than becoming a variant null + .fromCase(INT(), null, null) + .fromCase(STRING(), null, null), + // a constructed type casts element by element, and a NULL element stays SQL NULL + CastTestSpecBuilder.testCastTo(ARRAY(VARIANT())) + .fromCase( + ARRAY(INT()), + new GenericArrayData(new Integer[] {1, null}), + new GenericArrayData(new Object[] {VARIANT_BUILDER.of(1), null})), + CastTestSpecBuilder.testCastTo(ROW(FIELD("a", VARIANT()), FIELD("b", VARIANT()))) + .fromCase( + ROW(FIELD("a", INT()), FIELD("b", STRING())), + GenericRowData.of(7, null), + GenericRowData.of(VARIANT_BUILDER.of(7), null)), + CastTestSpecBuilder.testCastTo(MAP(STRING(), VARIANT())) + .fromCase( + MAP(STRING(), INT()), + mapData(entry(fromString("a"), 1)), + mapData(entry(fromString("a"), VARIANT_BUILDER.of(1)))), + CastTestSpecBuilder.testCastTo(MAP(VARIANT(), STRING())) + .fromCase( + MAP(INT(), STRING()), + mapData(entry(1, fromString("a"))), + mapData(entry(VARIANT_BUILDER.of(1), fromString("a")))), + CastTestSpecBuilder.testCastTo(MULTISET(VARIANT())) + .fromCase( + MULTISET(INT()), + mapData(entry(1, 2)), + mapData(entry(VARIANT_BUILDER.of(1), 2))), CastTestSpecBuilder.testCastTo(BOOLEAN()) .fromCase(VARIANT(), VARIANT_BUILDER.of(true), true) .fromCase(VARIANT(), VARIANT_BUILDER.of(false), false) @@ -2766,6 +2928,20 @@ class CastRulesTest { LocalDateTime.of(years, months, days, hours, minutes, seconds, nanos)); } + /** A nanosecond timestamp, which {@link VariantBuilder} only builds for sub-micro digits. */ + private static Variant timestampNanosVariant(LocalDateTime value) { + final BinaryVariantInternalBuilder builder = new BinaryVariantInternalBuilder(false); + builder.appendTimestampNanos( + ChronoUnit.NANOS.between(Instant.EPOCH, value.toInstant(ZoneOffset.UTC))); + return builder.build(); + } + + private static Variant timestampLtzNanosVariant(Instant value) { + final BinaryVariantInternalBuilder builder = new BinaryVariantInternalBuilder(false); + builder.appendTimestampLtzNanos(ChronoUnit.NANOS.between(Instant.EPOCH, value)); + return builder.build(); + } + private static TimestampData timestampDataFromInstant( int years, int months, int days, int hours, int minutes, int seconds, int nanos) { return TimestampData.fromInstant( 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 index 9f2deeb71d4..b834a157c76 100644 --- 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 @@ -21,6 +21,7 @@ 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.StringData; import org.apache.flink.table.data.TimestampData; import org.apache.flink.table.data.binary.BinaryStringData; import org.apache.flink.table.data.binary.BinaryStringDataUtil; @@ -28,18 +29,22 @@ import org.apache.flink.table.data.binary.StringUtf8Utils; import org.apache.flink.table.types.logical.utils.UuidUtils; import org.apache.flink.table.utils.DateTimeUtils; import org.apache.flink.table.utils.EncodingUtils; +import org.apache.flink.types.variant.BinaryVariantInternalBuilder; import org.apache.flink.types.variant.BinaryVariantUtil; import org.apache.flink.types.variant.Variant; +import org.apache.flink.types.variant.VariantBuilder; import org.apache.flink.types.variant.VariantTypeException; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.Arrays; import java.util.TimeZone; /** - * Runtime helpers for casting a {@code VARIANT} value to a SQL type. + * Runtime helpers for casting between a {@code VARIANT} value and 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 @@ -51,6 +56,9 @@ import java.util.TimeZone; * 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. + * + * <p>A cast into {@code VARIANT} keeps the kind of the SQL type, so an integer keeps its width and + * a timestamp keeps the precision it was declared with. */ @Internal public final class VariantCastUtils { @@ -62,7 +70,15 @@ public final class VariantCastUtils { */ private static final double LONG_MAGNITUDE_LIMIT = -(double) Long.MIN_VALUE; - private static final int TIMESTAMP_PRECISION = 6; + /** The highest timestamp precision a {@code VARIANT} stores with microseconds. */ + public static final int TIMESTAMP_PRECISION = 6; + + /** + * The largest string or binary value a {@code VARIANT} holds, in bytes. A {@code VARIANT} is + * limited to 16 MiB, and a long string or binary value spends 5 bytes of that on its header. + */ + public static final int MAX_PAYLOAD_BYTES = + BinaryVariantUtil.SIZE_LIMIT - 1 - BinaryVariantUtil.U32_SIZE; private static final int TIMESTAMP_NANOS_PRECISION = 9; @@ -71,6 +87,8 @@ public final class VariantCastUtils { private static final String NULL_NODE = "NULL"; + private static final VariantBuilder BUILDER = Variant.newBuilder(); + private VariantCastUtils() {} /** @@ -541,6 +559,131 @@ public final class VariantCastUtils { } } + public static Variant fromBoolean(boolean value) { + return BUILDER.of(value); + } + + /** + * Stores an integer in the integer kind of its SQL type, so a {@code BIGINT} stays a {@code + * BIGINT} whatever its value. The generated code passes the primitive of the source type, which + * selects the overload. + */ + public static Variant fromIntegral(byte value) { + return BUILDER.of(value); + } + + public static Variant fromIntegral(short value) { + return BUILDER.of(value); + } + + public static Variant fromIntegral(int value) { + return BUILDER.of(value); + } + + public static Variant fromIntegral(long value) { + return BUILDER.of(value); + } + + public static Variant fromFloat(float value) { + return BUILDER.of(value); + } + + public static Variant fromDouble(double value) { + return BUILDER.of(value); + } + + public static Variant fromDecimal(DecimalData value) { + return BUILDER.of(value.toBigDecimal()); + } + + public static Variant fromString(StringData value) { + try { + return BUILDER.of(value.toString()); + } catch (VariantTypeException e) { + throw sizeLimitExceeded(e, "string", value.toBytes().length); + } + } + + public static Variant fromBytes(byte[] value) { + try { + return BUILDER.of(value); + } catch (VariantTypeException e) { + throw sizeLimitExceeded(e, "binary", value.length); + } + } + + private static RuntimeException sizeLimitExceeded( + VariantTypeException e, String kind, int bytes) { + if (e != BinaryVariantInternalBuilder.VARIANT_SIZE_LIMIT_EXCEPTION) { + return e; + } + return new TableRuntimeException( + String.format( + "Cannot cast a %s value of %d bytes to VARIANT. A VARIANT is limited to " + + "16 MiB, so a string or binary value can have at most %d bytes.", + kind, bytes, MAX_PAYLOAD_BYTES)); + } + + public static Variant fromDate(int epochDay) { + return BUILDER.of(LocalDate.ofEpochDay(epochDay)); + } + + public static Variant fromTime(int millisOfDay) { + return BUILDER.of(LocalTime.ofNanoOfDay(millisOfDay * 1_000_000L)); + } + + /** + * Stores a timestamp in the kind its declared precision needs: microseconds up to {@code + * TIMESTAMP(6)} and nanoseconds above, whatever the digits of the value. A nanosecond kind only + * covers 1677-09-21 to 2262-04-11, so a {@code TIMESTAMP(7)} to {@code TIMESTAMP(9)} value + * outside that range fails. + */ + public static Variant fromTimestamp(TimestampData value, int precision) { + final BinaryVariantInternalBuilder builder = new BinaryVariantInternalBuilder(false); + if (precision <= TIMESTAMP_PRECISION) { + builder.appendTimestamp(timestampMicros(value)); + } else { + builder.appendTimestampNanos(timestampNanos(value, "TIMESTAMP(" + precision + ")")); + } + return builder.build(); + } + + /** Like {@link #fromTimestamp(TimestampData, int)}, for {@code TIMESTAMP_LTZ}. */ + public static Variant fromTimestampLtz(TimestampData value, int precision) { + final BinaryVariantInternalBuilder builder = new BinaryVariantInternalBuilder(false); + if (precision <= TIMESTAMP_PRECISION) { + builder.appendTimestampLtz(timestampMicros(value)); + } else { + builder.appendTimestampLtzNanos( + timestampNanos(value, "TIMESTAMP_LTZ(" + precision + ")")); + } + return builder.build(); + } + + /** Microseconds since the epoch, which cover every year a {@link TimestampData} can hold. */ + private static long timestampMicros(TimestampData value) { + return value.getMillisecond() * 1_000L + value.getNanoOfMillisecond() / 1_000; + } + + private static long timestampNanos(TimestampData value, String sourceType) { + try { + return Math.addExact( + Math.multiplyExact(value.getMillisecond(), 1_000_000L), + value.getNanoOfMillisecond()); + } catch (ArithmeticException e) { + throw new TableRuntimeException( + String.format( + "Cannot cast the %s value %s to VARIANT. A VARIANT timestamp with " + + "nanosecond precision only covers 1677-09-21 to 2262-04-11. " + + "Cast the value to a precision of 6 or less first.", + sourceType, value)); + } + } + + public static Variant fromUuid(byte[] value) { + return BUILDER.of(UuidUtils.fromBytes(value)); + } + private static TableRuntimeException unsupportedKind(Variant variant, String targetType) { return new TableRuntimeException( String.format( diff --git a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/functions/VariantCastUtilsTest.java b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/functions/VariantCastUtilsTest.java index f07452165d3..4492a94b234 100644 --- a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/functions/VariantCastUtilsTest.java +++ b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/functions/VariantCastUtilsTest.java @@ -18,6 +18,8 @@ package org.apache.flink.table.runtime.functions; +import org.apache.flink.table.api.TableRuntimeException; +import org.apache.flink.table.data.StringData; import org.apache.flink.types.variant.BinaryVariant; import org.apache.flink.types.variant.Variant; import org.apache.flink.types.variant.VariantBuilder; @@ -27,10 +29,14 @@ import org.junit.jupiter.api.Test; import java.time.Instant; import java.util.TimeZone; +import static org.apache.flink.table.runtime.functions.VariantCastUtils.MAX_PAYLOAD_BYTES; +import static org.apache.flink.table.runtime.functions.VariantCastUtils.fromBytes; +import static org.apache.flink.table.runtime.functions.VariantCastUtils.fromString; import static org.apache.flink.table.runtime.functions.VariantCastUtils.toPrintString; import static org.apache.flink.types.variant.BinaryVariantUtil.primitiveHeader; import static org.apache.flink.types.variant.BinaryVariantUtil.shortStrHeader; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class VariantCastUtilsTest { @@ -38,6 +44,27 @@ class VariantCastUtilsTest { private static final VariantBuilder BUILDER = Variant.newBuilder(); + @Test + void testCastToVariantHoldsUpToTheSizeLimit() { + assertThat(fromBytes(new byte[MAX_PAYLOAD_BYTES]).getBytes()).hasSize(MAX_PAYLOAD_BYTES); + assertThat(fromString(StringData.fromString("x".repeat(MAX_PAYLOAD_BYTES))).getString()) + .hasSize(MAX_PAYLOAD_BYTES); + } + + @Test + void testCastToVariantRejectsValuesOverTheSizeLimit() { + assertThatThrownBy(() -> fromBytes(new byte[MAX_PAYLOAD_BYTES + 1])) + .isInstanceOf(TableRuntimeException.class) + .hasMessage( + "Cannot cast a binary value of 16777212 bytes to VARIANT. A VARIANT is " + + "limited to 16 MiB, so a string or binary value can have at " + + "most 16777211 bytes."); + assertThatThrownBy( + () -> fromString(StringData.fromString("x".repeat(MAX_PAYLOAD_BYTES + 1)))) + .isInstanceOf(TableRuntimeException.class) + .hasMessageStartingWith("Cannot cast a string value of 16777212 bytes to VARIANT."); + } + @Test void testPrintRendersLikeCast() { final Variant variant =
