Ramin Gharib created FLINK-40319:
------------------------------------
Summary: 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
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
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.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)