[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
3750b25 to
f9bd3a0
Compare
| // 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?
There was a problem hiding this comment.
In Variant we don't and I don't think we should because it follows the JSON spec. Vendors like Snowflake and Databricks also don't allow it.
For cast we do CAST('Nan' AS FLOAT) and it is a valid case
| ```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 why we expect NULL after that
There was a problem hiding this comment.
to conclude on this: whereas for regular casts people opt-in on the overflow, for variant casts they should first extract the correct value before opt-in to overflow.
| 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 and `DATE` as a day count. There is no `TIME` kind. |
There was a problem hiding this comment.
why microsecond not nanoseconds?
There was a problem hiding this comment.
@davidradl This is how the implementation of the BinaryVariant defines it.
@Override
public Instant getInstant() throws VariantTypeException {
checkType(Type.TIMESTAMP_LTZ, getType());
return microsToInstant(BinaryVariantUtil.getLong(value, pos));
}The implementation is done based on the Encoding types defined in Parquet. Flink implements the types from 0 (NULL) to 16 (STRING). The rest of the types: 17 (Time Macro),18 Timestamp (Nano),19 TimestampNTZ (Nano), 20 (UUID) are not implemented in Flink yet.
| `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 and `DATE` as a day count. There is no `TIME` kind. | ||
|
|
There was a problem hiding this comment.
I am curious why Date does not go to Flink date which would seem more natural
There was a problem hiding this comment.
@davidradl I think the implementation of DATE is also based on Parquet: https://parquet.apache.org/docs/file-format/types/logicaltypes/
DATE is used for a logical date type, without a time of day. It must annotate an int32 that stores the number of days from the Unix epoch, 1 January 1970.
@Override
public LocalDate getDate() throws VariantTypeException {
checkType(Type.DATE, getType());
return LocalDate.ofEpochDay(BinaryVariantUtil.getLong(value, pos));
}02f5ffc to
e9c07c4
Compare
| value outside an integer or `DECIMAL` target's range fails `CAST` and returns `NULL` for | ||
| `TRY_CAST`. Other targets require the stored value to be of the matching kind, and a mismatch is | ||
| handled the same way. Casting a `VARIANT` to `CHAR`/`VARCHAR` extracts the scalar value, so a | ||
| stored string is returned unquoted (for example `foo`), while objects and arrays return their JSON |
There was a problem hiding this comment.
did you double check this behavior with other vendors? sounds incorrect to me.
while objects and arrays return their JSON
There was a problem hiding this comment.
this is what JSON_STRING should be good for.
There was a problem hiding this comment.
For objects, arrays, bytes we rely on the Variant#toJson() implementation. All other types (i.e., String) we convert to literal String. This is because for a case like CAST(PARSE_JSON('foo') AS STRING)) we want to return a string literal foo and not a JSON String "foo".
| LogicalType inputLogicalType, | ||
| LogicalType targetLogicalType) { | ||
| return methodCall(inputTerm, "toString"); | ||
| return staticCall(VariantCastUtils.class, "toStringValue", inputTerm); |
There was a problem hiding this comment.
according to LogicalTypeCasts, we support VARCHAR and CHAR both with various length. this cast rule should consider the target length as well. I guess it should throw if length exceeds?
There was a problem hiding this comment.
when the target is bounded CHAR(n)/VARCHAR(n), CharVarCharTrimPadCastRule kicks in, calls the variant→string rule, and trims/pads — the identical path every other to-string cast uses.
I added tests to prove it, and they pass:
- CAST(PARSE_JSON('"foobar"') AS VARCHAR(3)) → foo (trimmed)
- CAST(PARSE_JSON('"ab"') AS CHAR(5)) → ab (padded)
| // 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.
to conclude on this: whereas for regular casts people opt-in on the overflow, for variant casts they should first extract the correct value before opt-in to overflow.
| final DecimalData decimal = | ||
| DecimalData.fromBigDecimal(toBigDecimal(value), precision, scale); | ||
| if (decimal == null) { | ||
| throw new ArithmeticException( |
There was a problem hiding this comment.
is it guaranteed that this becomes a TableRuntimeException eventually? or how do we treat other cast cases currently?
There was a problem hiding this comment.
Changed it to TableRuntimeException
0a0d8d0 to
8f09c44
Compare
| * unquoted, unlike {@code JSON_STRING}), objects and arrays use JSON. | ||
| * Casts a {@code VARIANT} to its SQL string value for a {@code CHAR(targetLength)} or {@code | ||
| * VARCHAR(targetLength)} target. Scalars use their raw value (a string stays unquoted, unlike | ||
| * {@code JSON_STRING}); objects and arrays use their JSON representation. |
There was a problem hiding this comment.
objects and arrays use their JSON representation
Let's not do this. People can use JSON_STRING for this. Let stay conservative and throw an error. Maybe pointing towards JSON_STRING.
… 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.
4dc9135 to
23e41c6
Compare
twalthr
left a comment
There was a problem hiding this comment.
Another round of comments.
| 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 and `DATE` as a day count. There is no `TIME` kind. |
There was a problem hiding this comment.
| are stored with microsecond precision and `DATE` as a day count. There is no `TIME` kind. | |
| are stored with microsecond precision. |
| conversions like Avro. | ||
|
|
||
| The JSON specification has no `NaN` or infinity literals, so `PARSE_JSON('NaN')`, | ||
| `PARSE_JSON('Infinity')`, and `PARSE_JSON('-Infinity')` fail, and `TRY_PARSE_JSON` returns `NULL`. |
There was a problem hiding this comment.
also include the 1e400 example here.
| such as `PARSE_JSON('42')` casts to `INT` or `BIGINT`. Other targets require the stored value to be of | ||
| the matching kind. When the value cannot be converted, `CAST` fails the job and `TRY_CAST` returns | ||
| `NULL`. Use the `JSON_STRING` function to obtain the JSON string representation of a `VARIANT`. | ||
| targets accept any numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A |
There was a problem hiding this comment.
| targets accept any numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A | |
| targets accept any wider numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A |
| targets accept any numeric value, so `PARSE_JSON('42')` casts to `INT`, `BIGINT`, or `DOUBLE`. A | ||
| value outside an integer or `DECIMAL` target's range fails `CAST` and returns `NULL` for | ||
| `TRY_CAST`. Other targets require the stored value to be of the matching kind, and a mismatch is | ||
| handled the same way. Casting a `VARIANT` to `CHAR`/`VARCHAR` extracts the scalar value, so a |
There was a problem hiding this comment.
Let's not go into the details of CHAR/VARCHAR at this line
| handled the same way. Casting a `VARIANT` to `CHAR`/`VARCHAR` extracts the scalar value, so a | |
| handled the same way. Casting a `VARIANT` to `STRING` extracts the scalar value, so a |
| padding or truncation: `VARCHAR(n)` accepts up to `n` characters and `CHAR(n)` requires exactly `n`, | ||
| otherwise `CAST` fails and `TRY_CAST` returns `NULL`. |
There was a problem hiding this comment.
people should know how they work already
| padding or truncation: `VARCHAR(n)` accepts up to `n` characters and `CHAR(n)` requires exactly `n`, | |
| otherwise `CAST` fails and `TRY_CAST` returns `NULL`. | |
| padding or truncation. |
| return staticCall(VariantCastUtils.class, "toLongExact", number); | ||
| case FLOAT: | ||
| return "floatValue"; | ||
| return methodCall(number, "floatValue"); |
There was a problem hiding this comment.
Are there any data loss checks in this method? Asking AI leads to exactness loss.
can I store a full long in a float?
The short answer is yes, in terms of size (range), but no, in terms of exactness (precision).
If you attempt to store a large long inside a float, the application will not crash, but you will likely lose data through rounding errors.
| return methodCall(number, "floatValue"); | ||
| case DOUBLE: | ||
| return "doubleValue"; | ||
| return methodCall(number, "doubleValue"); |
There was a problem hiding this comment.
Are there any data loss checks in this method? Asking AI leads to exactness loss.
| * Casts a {@code VARIANT} to its SQL string value for a {@code CHAR(targetLength)} or {@code | ||
| * VARCHAR(targetLength)} target. Scalars use their raw value (a string stays unquoted, unlike | ||
| * {@code JSON_STRING}); a variant holding an object, array, or binary value is not castable and | ||
| * raises {@link TableRuntimeException}. | ||
| * | ||
| * <p>The target length is enforced strictly, with no padding or truncation: a {@code VARCHAR} | ||
| * value must not be longer than {@code targetLength}, and a {@code CHAR} value must match it | ||
| * exactly. A value that does not fit raises {@link TableRuntimeException}. |
There was a problem hiding this comment.
nit: Too much comment. Code is shorter than this JavaDoc.
| } | ||
|
|
||
| private static BigDecimal convertToBigDecimal(Number number) { | ||
| if (number == null) { |
|
|
||
| public static DecimalData toDecimalExact(Number value, int precision, int scale) { | ||
| final DecimalData decimal = | ||
| DecimalData.fromBigDecimal(toBigDecimal(value), precision, scale); |
There was a problem hiding this comment.
Where is max precision of 38 checked?
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 overflowingDECIMALfailsCASTand 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.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.Variant.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 (no signature change)VARIANT-to-numeric cast pathDocumentation
Was generative AI tooling used to co-author this PR?
Generated-by: Opus 4.8