[FLINK-37925][table] Reject out-of-range VARIANT casts and make VARIANT-to-string a value cast#28758
[FLINK-37925][table] Reject out-of-range VARIANT casts and make VARIANT-to-string a value cast#28758raminqaf wants to merge 6 commits into
Conversation
beec0e0 to
e03672b
Compare
f02dbdc to
49d4bca
Compare
bff4701 to
3750b25
Compare
… fit the target type and allow string cast Follow-up to the initial VARIANT-to-primitive cast support. Numeric casts from a VARIANT now reject values that do not fit the target type instead of silently wrapping: an out-of-range integer or an overflowing DECIMAL fails CAST and returns NULL for TRY_CAST, matching Spark's variant cast behavior. FLOAT and DOUBLE keep lenient IEEE conversion, where overflow becomes infinity. The new VariantCastUtils performs the checked narrowing by truncating toward zero and range-checking the value. CAST(VARIANT AS CHAR/VARCHAR) is now allowed and returns the JSON string representation, so the previous JSON_STRING-only restriction and its cast hint are removed. VARIANT cast validation is expressed directly in the per-target rules of LogicalTypeCasts. The VARIANT section of the data types reference documents the overflow behavior and the double-cast pattern for wrap-around narrowing.
…pe mapping Describe which value kinds a VARIANT can hold, including that TIMESTAMP and TIMESTAMP_LTZ use microsecond precision and DATE a day count, and that there is no TIME kind. Add a table showing how PARSE_JSON maps JSON values to variant kinds, and explain that PARSE_JSON cannot produce FLOAT, DATE, TIMESTAMP, TIMESTAMP_LTZ, or BYTES because JSON has no literal for them; those kinds come from other producers such as VariantBuilder or format conversions. Point the PARSE_JSON function reference to the VARIANT data type section for the mapping details.
…nt interface Note that getDateTime and getInstant return values with microsecond precision, which the LocalDateTime and Instant return types do not convey on their own. Fix the getInstant javadoc to reference Type.TIMESTAMP_LTZ, the type it actually accepts, instead of Type.TIMESTAMP.
NaN, Infinity, and -Infinity are not valid JSON, so parseJson throws and TRY_PARSE_JSON returns NULL. Lock the behavior in with a unit test.
3750b25 to
f9bd3a0
Compare
…nknown type-codes checkFullySupported walks the variant binary in place and throws VariantTypeException on the first value whose type-code Flink cannot decode, such as the extended Parquet/Spark codes for TIME, TIMESTAMP_NANOS, or UUID. It reads only headers and offsets, extracts no leaf values, and allocates no child variants.
| // NaN and the infinities are not valid JSON, so parsing fails: PARSE_JSON surfaces the | ||
| // error and TRY_PARSE_JSON returns NULL. |
There was a problem hiding this comment.
why do we assume that
IIRC Jackson has a property allowing to read them
There was a problem hiding this comment.
NaN and Infinity are not part of the JSON specification: https://www.rfc-editor.org/info/rfc8259/#section-6
Numeric values that cannot be represented in the grammar below (such as Infinity and NaN) are not permitted.
Jackson does not allow it by default and you need to switch a flag to allow it. To represent them, the user has to define them as string and cast them as float. The workaround is documented in the variant type
There was a problem hiding this comment.
then the question: why do we support these values in some parts of the code and do not support in others?
Do we have a plan to be consistent for all parts?
| ```sql | ||
| CAST(CAST(PARSE_JSON('1000') AS INT) AS TINYINT) -- returns -24 (wrap-around) | ||
| ``` | ||
| {{< /hint >}} |
There was a problem hiding this comment.
what is difference here from non VARIANT case?
| // Spark-style overflow: an integer value outside the target range fails | ||
| // CAST and returns NULL for TRY_CAST, instead of wrapping around. | ||
| .testTableApiRuntimeError( | ||
| call("PARSE_JSON", "40000").cast(SMALLINT()), "overflowed") | ||
| .testSqlRuntimeError("CAST(PARSE_JSON('40000') AS SMALLINT)", "overflowed") | ||
| .testResult( | ||
| call("PARSE_JSON", "40000").cast(SMALLINT()), | ||
| "CAST(PARSE_JSON('40000') AS SMALLINT)", | ||
| (short) -25536, | ||
| SMALLINT().notNull()) | ||
| .testResult( | ||
| call("PARSE_JSON", "128").cast(TINYINT()), | ||
| "CAST(PARSE_JSON('128') AS TINYINT)", | ||
| (byte) -128, | ||
| TINYINT().notNull()) | ||
| call("PARSE_JSON", "40000").tryCast(SMALLINT()), | ||
| "TRY_CAST(PARSE_JSON('40000') AS SMALLINT)", | ||
| null, | ||
| SMALLINT()) | ||
| .testTableApiRuntimeError( | ||
| call("PARSE_JSON", "128").cast(TINYINT()), "overflowed") |
There was a problem hiding this comment.
why do we do this this way?
Better to have same way as regular cast
Then there is SqlConformance#checkedArithmetic which we can make use to switch between different behavior
There was a problem hiding this comment.
The idea here is that the user first casts (converts) the value from variant to the actual type it has and then performs an explicit cast if they want too. In this case the 128 is an INT so the user first needs to cast it INT and then if they want to explicitly cast to TINYINT.
There was a problem hiding this comment.
yes, however it does not answer we expect NULL after that
What is the purpose of the change
Follow-up to the initial
VARIANT-to-primitive cast support (FLINK-37925). It tightens the cast semantics, turns the string cast into a real value cast, adds a decodability check, and documents the resulting behavior.VARIANTnow reject values that do not fit the target instead of silently wrapping. An out-of-range integer or an overflowingDECIMALfails
CASTand returnsNULLforTRY_CAST.FLOATandDOUBLEkeep lenient IEEE conversion, where overflow becomes infinity. This follows the behavior of Spark's variant casts.CAST(VARIANT AS CHAR/VARCHAR)now extracts the scalar value, so a stored string is returned unquoted (foo), while objects and arrays return their JSON representation.JSON_STRINGremains the way to get the JSON text, where a string stays quoted ("foo"). Previously the cast reused the JSON serialization and was indistinguishable fromJSON_STRING.BinaryVariant.checkFullySupported(), which walks a variant in place and throws on the first value whose type-code Flink cannot decode (the extended Parquet codes for TIME, nanosecond timestamps, and UUID). It reads only headers and offsets, extracting no leaf values.Brief change log
VariantCastUtils(flink-table-runtime): range-checked numeric narrowing (truncate toward zero, throw on overflow) and scalar-to-string extraction.VariantToPrimitiveCastRule: route integer andDECIMALtargets through the checked helpers; keepFLOAT/DOUBLElenient.VariantToStringCastRule: value extraction instead of JSON serialization;JSON_STRING(JsonStringCallGen) is left unchanged.LogicalTypeCasts: expressVARIANTcast validation in the per-target rules and drop theJSON_STRINGcast hint.BinaryVariant.checkFullySupported(); fix theVariant.getInstantjavadoc to referenceType.TIMESTAMP_LTZand document microsecond timestamp precision.VARIANTvalue encoding, thePARSE_JSONtype mapping, the string-cast vsJSON_STRINGdistinction, and thatNaN/infinity are not valid JSON (witha store-as-string workaround).Verifying this change
This change added and updated tests:
CastFunctionITCase: overflow cases (CASTfails,TRY_CASTreturnsNULL), lenientFLOAT/DOUBLE, and string-cast value extraction (a string returns unquoted, objects return JSON).LogicalTypeCastsTest: theVARIANTcast-support matrix.BinaryVariantInternalBuilderTest:PARSE_JSONrejectsNaN,Infinity, and-Infinity.BinaryVariantTest:checkFullySupportedaccepts supported types and rejects every primitive type-code above 16.Does this pull request potentially affect one of the following parts:
@Public(Evolving): yes, javadoc-only change to the@PublicEvolvingVariantinterface (nosignature change)
VARIANT-to-numeric cast pathDocumentation
Was generative AI tooling used to co-author this PR?
Generated-by: Opus 4.8