[ 
https://issues.apache.org/jira/browse/FLINK-40319?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Ramin Gharib updated FLINK-40319:
---------------------------------
    Description: 
h3. Problem

{{CAST(v AS BYTES)}} fails for a string variant, whatever the string holds. 
{{VariantCastUtils#toBytes}} calls {{Variant#getBytes}}, which accepts only the 
{{BYTES}} kind. For {{PARSE_JSON('"abc"')}} the getter throws a 
{{VariantTypeException}} with the message {{Expected type BYTES but got 
STRING}}.

This is a problem for three reasons:
* Regular SQL allows {{STRING}} to {{BINARY}}. VARIANT already allows the 
mirror direction, {{BYTES}} to a character string. Encoding a string to UTF-8 
always succeeds.
* JSON has no binary type, so {{PARSE_JSON}} never produces {{BYTES}}. A 
variant built from JSON can never reach a binary target, such as a 
{{MAP<STRING, BYTES>}} column.
* Every other kind fails the same way, with the getter's 
{{VariantTypeException}}. Most other readers, such as the numeric ones, throw a 
{{TableRuntimeException}} built by {{VariantCastUtils#unsupportedKind}} 
instead. Its message names the cast and the target type.

h3. Proposal

Change only {{VariantCastUtils#toBytes}}:
# Accept a stored {{STRING}} value through 
{{variant.getString().getBytes(StandardCharsets.UTF_8)}}, then pad or truncate 
it like a stored {{BYTES}} value.
# Reject every other kind with {{unsupportedKind}}.

{{Variant}}, {{BinaryVariant}} and {{BinaryVariantUtil}} stay unchanged.

h3. Observable changes

||Expression||Before||After||
|{{CAST(PARSE_JSON('"abc"') AS BYTES)}}|{{VariantTypeException}}|{{x'616263'}}|
|{{CAST(PARSE_JSON('"abc"') AS 
BINARY(2))}}|{{VariantTypeException}}|{{x'6162'}}|
|{{TRY_CAST(PARSE_JSON('"abc"') AS BYTES)}}|{{NULL}}|{{x'616263'}}|
|{{CAST(PARSE_JSON('true') AS 
BYTES)}}|{{VariantTypeException}}|{{TableRuntimeException}} with the 
{{unsupportedKind}} message|

The first row flips an existing expectation in {{CastRulesTest}}, which asserts 
that {{VARIANT_BUILDER.of("foo")}} fails a {{BYTES}} cast. The commit message 
calls this out. The table of cast targets in 
{{docs/content/docs/sql/reference/data-types.md}} gets a {{STRING}} to 
{{BINARY}} and {{VARBINARY}} entry.

h3. Limits

A stored {{STRING}} with an invalid UTF-8 payload loses its bytes. 
{{getString}} replaces malformed input with U+FFFD, so the cast returns {{EF BF 
BD}} in its place. {{CAST(v AS STRING)}} makes the same substitution today. 
Such a value can't come from the builders, which take a Java {{String}}, or 
from {{PARSE_JSON}}, which rejects invalid UTF-8. It can only come from bytes 
wrapped without validation, such as the binary encoding of the raw format from 
FLINK-41323. Validating string payloads at ingest, as FLINK-40318 proposes, 
would close this gap. A test pins the substitution.

h3. Out of scope

* A raw-bytes accessor for string values, and UTF-8 validation on the 
character-string cast. Ingest validation would make both unnecessary.
* The {{BOOLEAN}} reader and a malformed variant. FLINK-41301 covers them. The 
{{DATE}} reader already checks the kind since FLINK-41281.

h3. Tests

* In {{FromVariantConverterTest}}, a non-ASCII string variant reaches 
{{BYTES}}, {{VARBINARY(3)}} and {{BINARY(8)}} as its UTF-8 bytes, truncated or 
padded like a stored {{BYTES}} value. A string payload {{C3 28}} comes out as 
{{EF BF BD 28}}.
* In {{FromVariantConverterTest}}, a boolean, an integer and an object variant 
fail a binary cast with a {{TableRuntimeException}}. {{CastRulesTest}} can't 
show the exception type, because {{CastExecutor}} wraps any {{Throwable}} in a 
{{TableRuntimeException}}.
* In {{CastRulesTest}}, the string case of the {{BYTES}} cast passes.
* In {{CastFunctionITCase}}, {{CAST}} of a string variant to {{BYTES}} returns 
its bytes. {{CAST}} of a boolean one fails with the {{unsupportedKind}} 
message, and {{TRY_CAST}} returns {{NULL}}.


  was:
h3. Background

{\{BinaryVariantUtil}} can return the raw payload of a binary value but not of 
a string value. \{{getBinary}} copies the bytes out, while \{{getString}} 
decodes them in the same step:

{code:java}
// getBinary
return Arrays.copyOfRange(value, start, start + length);
// getString
return new String(value, start, length);
{code}

Two things follow from there being no undecoded accessor.

*A character-string cast cannot reject invalid UTF-8 for a string value.* 
FLINK-37925 added that check for a stored \{{BYTES}} value, where 
\{{Variant#getBytes}} hands over the raw bytes. A stored \{{STRING}} value has 
no equivalent, so the cast keeps the lenient decode and substitutes \{{U+FFFD}} 
for malformed input. The value reaching the query is then a character the 
stored bytes never held.

*A string variant cannot reach a binary target.* \{{VariantCastUtils#toBytes}} 
calls \{{Variant#getBytes}}, which rejects every kind other than \{{BYTES}}, so 
\{{CAST(v AS BYTES)}} fails for a string variant regardless of its content. 
That is inconsistent in two ways. Regular SQL allows \{{STRING}} to 
\{{BINARY}}, and the VARIANT rules already allow the mirror direction of 
\{{BYTES}} to a character string, justified on the grounds that a cast renders 
the way a regular cast of the stored kind would. It is also the infallible 
direction, since encoding a string to UTF-8 always succeeds while decoding can 
fail.

Together these leave a stored \{{STRING}} value with malformed bytes 
unreachable. The character-string cast silently returns replacement characters 
and the binary cast fails on a kind mismatch, so nothing shows what was 
actually stored.

h3. Scope

# Add \{{BinaryVariantUtil#getStringBytes(byte[], int)}}, parsing the 
\{{SHORT_STR}} and \{{LONG_STR}} header exactly as \{{getString}} does and 
returning \{{Arrays.copyOfRange}} as \{{getBinary}} does. Keep the header 
parsing duplicated instead of refactoring \{{getString}} to delegate. Spark 
already duplicates it between \{{getBinary}} and \{{getString}}, and delegating 
would add an array copy to every \{{getString}} call.
# Add \{{BinaryVariant#getStringBytes()}} guarded by \{{checkType(Type.STRING, 
getType())}}, mirroring \{{getBytes()}}. Do not add it to \{{Variant}}, which 
is \{{@PublicEvolving}}.
# In \{{VariantCastUtils#toStringValue}}, collapse the \{{STRING}} and 
\{{BYTES}} branches. Both become read the raw bytes, validate, decode. One 
message then covers both, and the advice to cast to \{{BYTES}} becomes true for 
both.
# In \{{VariantCastUtils#toBytes}}, accept a stored \{{STRING}} value through 
its UTF-8 bytes, and reject every other kind with \{{unsupportedKind}} instead 
of letting \{{VariantTypeException}} escape and be rewrapped.

h3. Observable changes

|| Expression on a string variant || Before || After ||
| \{{CAST(v AS STRING)}}, malformed bytes | returns \{{U+FFFD}} | fails, 
reporting the first invalid byte |
| \{{CAST(v AS BYTES)}} | fails on a kind mismatch | returns the stored bytes |

The second row flips an existing expectation in \{{CastRulesTest}}, which 
asserts that a string variant fails a \{{BYTES}} cast. It becomes a passing 
case, so it should be called out in the commit message rather than looking like 
a test fixup. The prose table of cast targets in 
\{{docs/content/docs/sql/reference/data-types.md}} needs a \{{STRING}} to 
\{{BINARY}} and \{{VARBINARY}} entry. The support matrix in the same file 
already documents VARIANT to \{{BYTES}} as fallible and needs no change.

h3. Notes

{\{Variant}} stays untouched, so \{{BinaryVariant}} has to be reached through 
an \{{instanceof}} check in \{{VariantCastUtils}}. That is safe in practice. 
\{{BinaryVariant}} is \{{final}} and the only implementation, and 
\{{VariantType}} permits only \{{Variant}} and \{{BinaryVariant}} as conversion 
classes.

Do not generalise the accessor to every stored kind. \{{BOOLEAN}} and \{{NULL}} 
carry no payload bytes at all, because the value lives in the header type-info 
bits. A numeric payload is a little-endian detail of the variant encoding 
rather than a portable value. Regular SQL also rejects \{{INTEGER}} to 
\{{BINARY}}, so accepting every kind would make a VARIANT cast more permissive 
than a regular one. For inspecting the encoding of an arbitrary kind, 
\{{BinaryVariant#getValue}} and \{{BinaryVariant#getMetadata}} already exist, 
and a SQL-level equivalent belongs in a dedicated function rather than in 
\{{CAST}}.

The character-string cast pays one extra array copy per row, since 
\{{getStringBytes}} copies before the validation walk. Validation at ingest 
would make the check redundant and allow removing it.

h3. Tests

{\{VariantBuilder}} takes a \{{String}}, so it cannot produce a string value 
with malformed bytes. Build one directly with \{{new BinaryVariant(value, 
metadata)}}, using \{{BinaryVariantUtil#shortStrHeader}} for the header byte 
and a single \{{BinaryVariantUtil#VERSION}} byte as metadata. Cover a non-ASCII 
string round-tripping through both targets, and a malformed string value 
failing the character-string cast while still reaching \{{BYTES}} byte for byte.

        Summary: Allow CAST from a VARIANT string value to BINARY and VARBINARY 
 (was:   Add a raw-bytes accessor for VARIANT string values, and use it to 
validate UTF-8 on the character-string cast and to let a string variant reach a 
binary target)

> Allow CAST from a VARIANT string value to BINARY and VARBINARY
> --------------------------------------------------------------
>
>                 Key: FLINK-40319
>                 URL: https://issues.apache.org/jira/browse/FLINK-40319
>             Project: Flink
>          Issue Type: Improvement
>          Components: API / Core
>            Reporter: Ramin Gharib
>            Assignee: Ramin Gharib
>            Priority: Major
>              Labels: pull-request-available
>
> h3. Problem
> {{CAST(v AS BYTES)}} fails for a string variant, whatever the string holds. 
> {{VariantCastUtils#toBytes}} calls {{Variant#getBytes}}, which accepts only 
> the {{BYTES}} kind. For {{PARSE_JSON('"abc"')}} the getter throws a 
> {{VariantTypeException}} with the message {{Expected type BYTES but got 
> STRING}}.
> This is a problem for three reasons:
> * Regular SQL allows {{STRING}} to {{BINARY}}. VARIANT already allows the 
> mirror direction, {{BYTES}} to a character string. Encoding a string to UTF-8 
> always succeeds.
> * JSON has no binary type, so {{PARSE_JSON}} never produces {{BYTES}}. A 
> variant built from JSON can never reach a binary target, such as a 
> {{MAP<STRING, BYTES>}} column.
> * Every other kind fails the same way, with the getter's 
> {{VariantTypeException}}. Most other readers, such as the numeric ones, throw 
> a {{TableRuntimeException}} built by {{VariantCastUtils#unsupportedKind}} 
> instead. Its message names the cast and the target type.
> h3. Proposal
> Change only {{VariantCastUtils#toBytes}}:
> # Accept a stored {{STRING}} value through 
> {{variant.getString().getBytes(StandardCharsets.UTF_8)}}, then pad or 
> truncate it like a stored {{BYTES}} value.
> # Reject every other kind with {{unsupportedKind}}.
> {{Variant}}, {{BinaryVariant}} and {{BinaryVariantUtil}} stay unchanged.
> h3. Observable changes
> ||Expression||Before||After||
> |{{CAST(PARSE_JSON('"abc"') AS 
> BYTES)}}|{{VariantTypeException}}|{{x'616263'}}|
> |{{CAST(PARSE_JSON('"abc"') AS 
> BINARY(2))}}|{{VariantTypeException}}|{{x'6162'}}|
> |{{TRY_CAST(PARSE_JSON('"abc"') AS BYTES)}}|{{NULL}}|{{x'616263'}}|
> |{{CAST(PARSE_JSON('true') AS 
> BYTES)}}|{{VariantTypeException}}|{{TableRuntimeException}} with the 
> {{unsupportedKind}} message|
> The first row flips an existing expectation in {{CastRulesTest}}, which 
> asserts that {{VARIANT_BUILDER.of("foo")}} fails a {{BYTES}} cast. The commit 
> message calls this out. The table of cast targets in 
> {{docs/content/docs/sql/reference/data-types.md}} gets a {{STRING}} to 
> {{BINARY}} and {{VARBINARY}} entry.
> h3. Limits
> A stored {{STRING}} with an invalid UTF-8 payload loses its bytes. 
> {{getString}} replaces malformed input with U+FFFD, so the cast returns {{EF 
> BF BD}} in its place. {{CAST(v AS STRING)}} makes the same substitution 
> today. Such a value can't come from the builders, which take a Java 
> {{String}}, or from {{PARSE_JSON}}, which rejects invalid UTF-8. It can only 
> come from bytes wrapped without validation, such as the binary encoding of 
> the raw format from FLINK-41323. Validating string payloads at ingest, as 
> FLINK-40318 proposes, would close this gap. A test pins the substitution.
> h3. Out of scope
> * A raw-bytes accessor for string values, and UTF-8 validation on the 
> character-string cast. Ingest validation would make both unnecessary.
> * The {{BOOLEAN}} reader and a malformed variant. FLINK-41301 covers them. 
> The {{DATE}} reader already checks the kind since FLINK-41281.
> h3. Tests
> * In {{FromVariantConverterTest}}, a non-ASCII string variant reaches 
> {{BYTES}}, {{VARBINARY(3)}} and {{BINARY(8)}} as its UTF-8 bytes, truncated 
> or padded like a stored {{BYTES}} value. A string payload {{C3 28}} comes out 
> as {{EF BF BD 28}}.
> * In {{FromVariantConverterTest}}, a boolean, an integer and an object 
> variant fail a binary cast with a {{TableRuntimeException}}. 
> {{CastRulesTest}} can't show the exception type, because {{CastExecutor}} 
> wraps any {{Throwable}} in a {{TableRuntimeException}}.
> * In {{CastRulesTest}}, the string case of the {{BYTES}} cast passes.
> * In {{CastFunctionITCase}}, {{CAST}} of a string variant to {{BYTES}} 
> returns its bytes. {{CAST}} of a boolean one fails with the 
> {{unsupportedKind}} message, and {{TRY_CAST}} returns {{NULL}}.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to