Skip to content

[FLINK-37925][table] Define VARIANT cast rules so a cast never alters the stored value - #28758

Open
raminqaf wants to merge 3 commits into
apache:masterfrom
raminqaf:FLINK-37925-followup
Open

[FLINK-37925][table] Define VARIANT cast rules so a cast never alters the stored value#28758
raminqaf wants to merge 3 commits into
apache:masterfrom
raminqaf:FLINK-37925-followup

Conversation

@raminqaf

@raminqaf raminqaf commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What is the purpose of the change

Follow-up to the initial VARIANT to primitive cast support in FLINK-37925. It settles the cast semantics under one rule and turns the string cast into a real value cast.

A cast from a VARIANT succeeds only when the target holds the stored value without altering it. A value is never wrapped, rounded, truncated, or padded to make it fit; anything else fails CAST and returns NULL for TRY_CAST.

Stored kind Succeeds for
integer kinds any numeric target that holds the value, DECIMAL, FLOAT, DOUBLE
FLOAT, DOUBLE FLOAT, DOUBLE
DECIMAL DECIMAL without rounding, FLOAT, DOUBLE
BOOLEAN, DATE the same type
TIMESTAMP TIMESTAMP(p) that keeps the fractional seconds
TIMESTAMP_LTZ TIMESTAMP_LTZ(p) that keeps the fractional seconds
BYTES BINARY(n), VARBINARY(n)
any scalar CHAR(n), VARCHAR(n), STRING
NULL SQL NULL for any nullable target

The conditions in that table are:

  • 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 the one exception to exactness. They are approximate by definition, so they accept every numeric kind and drop decimal digits, rejecting only a magnitude they cannot represent such as 1e40 to a FLOAT.
  • CHAR(n) requires the exact length and VARCHAR(n) at most n. Nothing is padded or truncated.
    Reading one kind as another is never implicit, so a DECIMAL is not read as an integer and a TIMESTAMP is not read as a TIMESTAMP_LTZ. To reach such a type the inner cast names the stored kind and a regular cast around it does the conversion, which then applies the usual rules and may round, truncate, or overflow:
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)                                                                                                                                  

Casting to a character string

CAST(VARIANT AS CHAR/VARCHAR) now renders the value exactly as a regular SQL cast of the stored kind would, instead of reusing the JSON serialization. So a boolean becomes TRUE rather than true, a timestamp uses the SQL format 2021-09-24 12:34:56.123456 rather than the ISO form with a T, and a TIMESTAMP_LTZ is shifted into the session time zone rather than always printed as UTC. This is implemented by calling the same runtime formatters the native to-string rules call, so the two cannot drift apart, and the tests assert against the very constants the native cases use.

JSON_STRING remains the way to obtain the JSON text, where a string stays quoted as "foo" and an object or array is serialized. A variant holding an object, array, or binary value has no scalar rendering, so that cast fails and the error points to JSON_STRING. A variant storing a JSON null casts to SQL NULL rather than to the text null.

Kinds that PARSE_JSON cannot produce

The same rule applies to the kinds a variant can hold but PARSE_JSON never creates. A timestamp reaches a TIMESTAMP whose precision keeps its fractional seconds, since a variant always stores microseconds, and a binary value reaches BINARY(n) of the exact length or VARBINARY(n) at most n. TIMESTAMP and TIMESTAMP_LTZ are separate kinds and are not read as one another. These paths are only reachable through the programmatic VariantBuilder today.

TIME has no counterpart in the variant type model and is rejected during validation.

Brief change log

  • Add VariantCastUtils in flink-table-runtime holding the per-target checks: integer range, DECIMAL precision and scale, floating-point magnitude, timestamp precision, and character or binary length.
  • VariantToPrimitiveCastRule: route every target through those checks. This also fixes two cases that silently altered the value, namely a timestamp keeping more precision than its declared type and a binary
    value being padded or truncated to the target length.
  • VariantToStringCastRule: extract the scalar value instead of serializing to JSON, handle CHAR and VARCHAR directly to enforce the length, and map a null-valued variant to SQL NULL for a nullable target.
  • LogicalTypeCasts: express VARIANT cast validation in the per-target rules and drop the JSON_STRING cast hint.

Verifying this change

This change added and updated tests:

  • CastRulesTest: per-kind coverage of every numeric target including range rejection, DECIMAL precision and scale fitting, the FLOAT and DOUBLE acceptance of any numeric kind with overflow rejection, the timestamp precision rule, and the TIMESTAMP versus TIMESTAMP_LTZ split.
  • CastFunctionITCase: the same through PARSE_JSON, plus string value extraction, strict CHAR and VARCHAR length, rejection of object and array values, a JSON null casting to SQL NULL, and the two nested casts above. Each failing case also asserts that TRY_CAST returns NULL.
  • LogicalTypeCastsTest: the VARIANT cast support matrix.

Trade-offs

Rejecting a cast that would change the value is stricter than Snowflake and Spark, which coerce. For the kind-preserving part it matches Snowflake's AS_INTEGER and AS_DOUBLE accessor family, where a mismatch yields NULL. The reasoning is that a VARIANT cast is an extraction step, so any conversion decision belongs in an explicit second cast, and TRY_CAST provides the non-failing form.

Because the stored kind is only known per row, these are runtime failures and cannot be reported during validation.

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): yes, javadoc-only change to the @PublicEvolving Variant interface, no signature change
  • The serializers: no
  • The runtime per-record code paths (performance sensitive): yes, only on the VARIANT cast path
  • Anything that affects deployment or recovery: no
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? no, it refines existing FLINK-37925 behavior
  • If yes, how is the feature documented? docs and JavaDocs

@flinkbot

flinkbot commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 2 times, most recently from beec0e0 to e03672b Compare July 16, 2026 10:07
Comment thread docs/content/docs/sql/reference/data-types.md Outdated
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 2 times, most recently from f02dbdc to 49d4bca Compare July 16, 2026 10:45
@github-actions github-actions Bot added the community-reviewed PR has been reviewed by the community. label Jul 16, 2026
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 6 times, most recently from 3750b25 to f9bd3a0 Compare July 17, 2026 11:38
@raminqaf raminqaf changed the title [FLINK-37925][table] Range-check VARIANT numeric casts and allow casting VARIANT to string [FLINK-37925][table] Reject out-of-range VARIANT casts and make VARIANT-to-string a value cast Jul 17, 2026
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content/docs/sql/reference/data-types.md Outdated
Comment thread docs/content/docs/sql/reference/data-types.md
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 02f5ffc to e9c07c4 Compare July 21, 2026 09:41

@twalthr twalthr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @raminqaf. I left some feedback.

Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 0a0d8d0 to 8f09c44 Compare July 22, 2026 11:00
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 4dc9135 to 23e41c6 Compare July 22, 2026 16:31

@twalthr twalthr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another round of comments.

Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
Comment thread docs/content.zh/docs/sql/reference/data-types.md Outdated
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 23e41c6 to ac56197 Compare July 24, 2026 10:55
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 3 times, most recently from c0cc0b7 to 3a11673 Compare July 29, 2026 15:48
@raminqaf raminqaf changed the title [FLINK-37925][table] Reject out-of-range VARIANT casts and make VARIANT-to-string a value cast [FLINK-37925][table] Require an exact kind match for VARIANT casts and make VARIANT-to-string a value cas Jul 29, 2026
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 2 times, most recently from 30006cb to 1da4431 Compare July 29, 2026 15:55
@raminqaf raminqaf changed the title [FLINK-37925][table] Require an exact kind match for VARIANT casts and make VARIANT-to-string a value cas [FLINK-37925][table] Define VARIANT cast rules so a cast never alters the stored value Jul 30, 2026
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 2 times, most recently from 55b0abc to 83b53d4 Compare July 30, 2026 12:51

@twalthr twalthr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @raminqaf. We are almost there. But I had to post some more critical comments.

@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 4 times, most recently from cb0c8be to 0c314b5 Compare July 31, 2026 12:38
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 0c314b5 to 709ec03 Compare August 3, 2026 09:02

@twalthr twalthr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thank you @raminqaf for addressing all the feedback comments!

… the stored value

A cast from a VARIANT succeeds only when the target holds the stored value without altering it, so a value is never wrapped, rounded, truncated, or padded to make it fit. Anything else fails `CAST` and returns `NULL` for `TRY_CAST`.

An integer converts to any integer target that has room for it, and to a `DECIMAL` that holds it exactly. A `DECIMAL` converts to a `DECIMAL` whose precision and scale keep every digit, where trailing zeros may be appended but a scale that would round is rejected. A timestamp converts to a `TIMESTAMP` whose precision keeps its fractional seconds, which matters because a variant always stores microseconds. `CHAR` and `BINARY` require the exact length while `VARCHAR` and `VARBINARY` accept anything shorter.

`FLOAT` and `DOUBLE` are the exception. They are approximate by definition, so they accept every numeric kind and drop decimal digits, rejecting only a magnitude they cannot represent at all.

Reading one kind as another is never implicit. A `DECIMAL` is not read as an integer, and a `TIMESTAMP` is not read as a `TIMESTAMP_LTZ`. To reach such a type, the inner cast names the stored kind and a regular cast around it performs the conversion, which then applies the usual rules and may round, truncate, or overflow.

Casting to a character string extracts the scalar value, so a stored string is returned unquoted. Previously this reused the JSON serialization and was indistinguishable from `JSON_STRING`, which stays the way to obtain the JSON text. A variant holding an object, array, or binary value is not castable to a character string, and a variant storing a JSON `null` casts to SQL `NULL`.

`TIME` has no counterpart in the variant type model and is rejected during validation.
Describe which kind a `VARIANT` stores for each JSON input, since `PARSE_JSON` picks the smallest integer kind that holds a value and stores a plain-notation number as a `DECIMAL` but a scientific-notation one as a `DOUBLE`. Note that `NaN` and infinity are not valid JSON, and show how to keep such a value as a string instead.

Add a table of the cast targets each stored kind reaches, with the range, precision, scale, and length conditions that apply, and the pattern for reaching a type the table does not list.

Fix the `Variant.getInstant` javadoc to reference `Type.TIMESTAMP_LTZ` rather than `Type.TIMESTAMP`, document that a timestamp is stored with microsecond precision, and link the referenced types.
Result display builds a cast to `STRING` for every column, so restricting the VARIANT cast to scalar values also made a variant holding an object or an array undisplayable. `SELECT PARSE_JSON('[1,"two",false,null]')` failed in `TableResult#print`, in the SQL client, and in the SQL gateway, which covers the main use case of the type.

Render every variant as JSON when the cast context is printing. `CastRule.Context` already carries that flag and `BinaryToStringCastRule` already branches on it, so a display path keeps working while `CAST` stays strict. This restores what a result looked like before the cast rules were defined, including a stored string printing quoted.

Handling this in the cast rule rather than in `RowDataToStringConverterImpl` also covers a variant nested in an `ARRAY`, `MAP`, or `ROW` column, because the printing flag propagates to the child rules that render the elements.
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch from 488f31f to c110934 Compare August 4, 2026 11:49

@twalthr twalthr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-reviewed PR has been reviewed by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants