Skip to content

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

Open
raminqaf wants to merge 2 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 2 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 an integer target in range, 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 rejects a value out of its range, so 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 round is rejected.
  • FLOAT and DOUBLE are the deliberate exception. 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.
  • A timestamp keeps microseconds in a variant, so the target precision has to preserve its fractional seconds: .123 reaches TIMESTAMP(3) while .123456 does not.
  • CHAR(n) and BINARY(n) require the exact length, VARCHAR(n) and VARBINARY(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)                                                                                                                                  

CAST(VARIANT AS CHAR/VARCHAR) now extracts the scalar value, so a stored string is returned unquoted as foo. Previously it reused the JSON serialization and was indistinguishable from JSON_STRING, which
remains the way to obtain the JSON text where a string stays quoted as "foo". 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 rather than to the text null.

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 from 1da4431 to 55b0abc Compare July 30, 2026 12:24
raminqaf added 2 commits July 30, 2026 14:48
… 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.
@raminqaf
raminqaf force-pushed the FLINK-37925-followup branch 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.

Comment on lines +1587 to +1591
.fail(VARIANT(), Variant.newBuilder().of(7.0d), TableRuntimeException.class)
.fail(
VARIANT(),
Variant.newBuilder().of(new BigDecimal("7.0")),
TableRuntimeException.class)

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.

This one we could rediscuss, right?

Comment on lines +263 to +272
.testResult(
call("PARSE_JSON", "123.456").cast(STRING()),
"CAST(PARSE_JSON('123.456') AS STRING)",
"123.456",
STRING().notNull())
.testResult(
call("PARSE_JSON", "true").cast(STRING()),
"CAST(PARSE_JSON('true') AS STRING)",
"true",
STRING().notNull())

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.

This we did not discuss. Let's not allow this for now. Only string can be cast to string.

* scale.
*/
public static DecimalData toDecimal(Variant variant, int precision, int scale) {
final String targetType = String.format("DECIMAL(%d, %d)", precision, scale);

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.

only format on error, keep the hot path performant

* VARCHAR} an upper bound).
*/
public static String toStringValue(Variant variant, int targetLength, boolean charTarget) {
final String targetType =

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.

same comment as above

default:
// Scalars are cast to their raw string value.
}
final String value = variant.get().toString();

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.

this is again JSON and not SQL. e.g. TRUE vs true for variant boolean to string. as mentioned above. only string variant can cast to string. if you want to support more, come up with a proper string representation for each variant type. incl TS_LTZ in users time zone.

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