This is an automated email from the ASF dual-hosted git repository.
twalthr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 44edc3c9694 [FLINK-40580][table] Render VARIANT object/array as SQL
when casting to character string
44edc3c9694 is described below
commit 44edc3c9694c46a45a62d53d20f98fa595e9559a
Author: Ramin Gharib <[email protected]>
AuthorDate: Wed Sep 9 11:25:40 2026 +0200
[FLINK-40580][table] Render VARIANT object/array as SQL when casting to
character string
This closes #29125.
---
docs/content.zh/docs/sql/reference/data-types.md | 11 +--
docs/content/docs/sql/reference/data-types.md | 11 +--
.../functions/casting/VariantToStringCastRule.java | 10 ++-
.../planner/functions/CastFunctionITCase.java | 41 ++++++++--
.../planner/functions/casting/CastRulesTest.java | 31 +++++--
.../table/runtime/functions/VariantCastUtils.java | 94 ++++++++++++++++------
6 files changed, 146 insertions(+), 52 deletions(-)
diff --git a/docs/content.zh/docs/sql/reference/data-types.md
b/docs/content.zh/docs/sql/reference/data-types.md
index 9bc67dd733e..fa20d4c273f 100644
--- a/docs/content.zh/docs/sql/reference/data-types.md
+++ b/docs/content.zh/docs/sql/reference/data-types.md
@@ -1583,10 +1583,11 @@ CAST(CAST(PARSE_JSON('3.9') AS DECIMAL(2, 1)) AS INT)
-- returns 3 (truncate
A cast to a character string renders the value exactly as a regular SQL cast
of the stored kind
would, so a boolean becomes `TRUE`, a timestamp uses the SQL format, a
`TIMESTAMP_LTZ` is shifted into
-the session time zone, and a binary value is read as UTF-8. Only an object or
an array has no such
-rendering. Use `JSON_STRING` for the JSON representation instead, where a
string stays quoted as
-`"foo"` and an object or array is serialized. A variant that stores a JSON
`null` casts to SQL
-`NULL`.
+the session time zone, and a binary value is read as UTF-8. An object or an
array has no scalar
+form, so it renders like a regular `ARRAY` or `MAP` cast to a string: an array
as `[e1, e2]` and an
+object as `{k1=v1, k2=v2}`, with each value rendered by these same rules and a
nested variant null
+shown as `NULL`. A string is never quoted, at any depth. Use `JSON_STRING` for
the JSON form with
+quoted strings. A variant that stores a JSON `null` casts to SQL `NULL`.
A `VARIANT` can also be cast to a constructed target, which imposes a schema
on it. A variant array
casts to `ARRAY<T>`. The variant must be an array, otherwise the cast fails.
Each element is itself a
@@ -1873,7 +1874,7 @@ COALESCE(TRY_CAST('non-number' AS INT), 0) --- 结果返回数字 0 的
INT 格
| `ROW` | Y
| N | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | !³
| N | N | N | N |
| `STRUCTURED` | Y
| N | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| !³ | N | N | N |
| `RAW` | Y
| ! | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| N | Y⁴ | N | N |
-| `VARIANT` | N
| ! | ! | ! | !
| ! | ! | ! | ! | ! | ! | N |
! | ! | N | !³ | N | !³ | !³
| !³ | N | Y | N |
+| `VARIANT` | !
| ! | ! | ! | !
| ! | ! | ! | ! | ! | ! | N |
! | ! | N | !³ | N | !³ | !³
| !³ | N | Y | N |
| `BITMAP` | Y
| Y⁷ | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| N | N | N | N |
备注:
diff --git a/docs/content/docs/sql/reference/data-types.md
b/docs/content/docs/sql/reference/data-types.md
index 8e43c2ec12e..e62f1997d29 100644
--- a/docs/content/docs/sql/reference/data-types.md
+++ b/docs/content/docs/sql/reference/data-types.md
@@ -1591,10 +1591,11 @@ CAST(CAST(PARSE_JSON('3.9') AS DECIMAL(2, 1)) AS INT)
-- returns 3 (truncate
A cast to a character string renders the value exactly as a regular SQL cast
of the stored kind
would, so a boolean becomes `TRUE`, a timestamp uses the SQL format, a
`TIMESTAMP_LTZ` is shifted into
-the session time zone, and a binary value is read as UTF-8. Only an object or
an array has no such
-rendering. Use `JSON_STRING` for the JSON representation instead, where a
string stays quoted as
-`"foo"` and an object or array is serialized. A variant that stores a JSON
`null` casts to SQL
-`NULL`.
+the session time zone, and a binary value is read as UTF-8. An object or an
array has no scalar
+form, so it renders like a regular `ARRAY` or `MAP` cast to a string: an array
as `[e1, e2]` and an
+object as `{k1=v1, k2=v2}`, with each value rendered by these same rules and a
nested variant null
+shown as `NULL`. A string is never quoted, at any depth. Use `JSON_STRING` for
the JSON form with
+quoted strings. A variant that stores a JSON `null` casts to SQL `NULL`.
A `VARIANT` can also be cast to a constructed target, which imposes a schema
on it. A variant array
casts to `ARRAY<T>`. The variant must be an array, otherwise the cast fails.
Each element is itself a
@@ -1882,7 +1883,7 @@ The matrix below describes the supported cast pairs,
where "Y" means supported,
| `ROW` | Y
| N | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | !³
| N | N | N | N |
| `STRUCTURED` | Y
| N | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| !³ | N | N | N |
| `RAW` | Y
| ! | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| N | Y⁴ | N | N |
-| `VARIANT` | N
| ! | ! | ! | !
| ! | ! | ! | ! | ! | ! | N |
! | ! | N | !³ | N | !³ | !³
| !³ | N | Y | N |
+| `VARIANT` | !
| ! | ! | ! | !
| ! | ! | ! | ! | ! | ! | N |
! | ! | N | !³ | N | !³ | !³
| !³ | N | Y | N |
| `BITMAP` | Y
| Y⁷ | N | N | N
| N | N | N | N | N | N | N |
N | N | N | N | N | N | N
| N | N | N | N |
Notes:
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java
index eeb9fcec9f6..08cef8e0b3f 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToStringCastRule.java
@@ -36,7 +36,9 @@ import static
org.apache.flink.table.planner.functions.casting.CastRuleUtils.sta
* <p>Renders the scalar value the way a regular SQL cast of the stored kind
would, so a boolean
* becomes {@code TRUE}, a timestamp uses the SQL format, and a {@code
TIMESTAMP_LTZ} is shifted
* into the session time zone and a binary value is read as UTF-8. A variant
holding an object or
- * array has no scalar rendering and fails; use {@code JSON_STRING} for its
JSON representation.
+ * array has no scalar form, so it renders like a regular {@code MAP} or
{@code ARRAY} to string
+ * cast, an array as {@code [e1, e2]} and an object as {@code {k1=v1, k2=v2}},
with strings unquoted
+ * at every depth. This is a SQL rendering, not JSON; use {@code JSON_STRING}
for the JSON form.
*
* <p>A binary value that is not well-formed UTF-8 fails instead of decoding
to {@code U+FFFD}. Cast
* it to {@code BYTES} to inspect the raw value, or wrap that in {@code
MAKE_VALID_UTF8} to accept
@@ -97,9 +99,9 @@ class VariantToStringCastRule extends
AbstractExpressionCodeGeneratorCastRule<Va
LogicalType inputLogicalType,
LogicalType targetLogicalType) {
if (context.isPrinting()) {
- // Every result has to be displayable, including an object or an
array, which have no
- // scalar rendering and would fail the cast. toJson returns a
String, so it needs the
- // wrap that toStringValue applies itself.
+ // Printing renders every variant as JSON, so a scalar string
shows quoted rather than
+ // extracted as the cast below would. toJson returns a String, so
it needs the wrap that
+ // toStringValue applies itself.
return staticCall(BINARY_STRING_DATA_FROM_STRING(),
methodCall(inputTerm, "toJson"));
}
return staticCall(
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java
index b73054fbbf1..7dbbc379c67 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/CastFunctionITCase.java
@@ -301,21 +301,27 @@ public class CastFunctionITCase extends
BuiltInFunctionTestBase {
"CAST(PARSE_JSON('true') AS STRING)",
"TRUE",
STRING().notNull())
- // An object or array has no scalar value, so the
error points to
- // JSON_STRING.
- .testTableApiRuntimeError(
- lit("[\"a\",
\"b\"]").parseJson().cast(STRING()), "JSON_STRING")
+ // An object or array has no scalar form, so it
renders like a regular ARRAY
+ // or MAP to string cast, with strings unquoted at
every depth.
+ .testResult(
+ lit("[\"a\",
\"b\"]").parseJson().cast(STRING()),
+ "CAST(PARSE_JSON('[\"a\", \"b\"]') AS STRING)",
+ "[a, b]",
+ STRING().notNull())
.testResult(
lit("[\"a\",
\"b\"]").parseJson().tryCast(STRING()),
"TRY_CAST(PARSE_JSON('[\"a\", \"b\"]') AS
STRING)",
- null,
+ "[a, b]",
STRING())
- .testTableApiRuntimeError(
- lit("{\"a\": 1}").parseJson().cast(STRING()),
"JSON_STRING")
+ .testResult(
+ lit("{\"a\": 1}").parseJson().cast(STRING()),
+ "CAST(PARSE_JSON('{\"a\": 1}') AS STRING)",
+ "{a=1}",
+ STRING().notNull())
.testResult(
lit("{\"a\":
1}").parseJson().tryCast(STRING()),
"TRY_CAST(PARSE_JSON('{\"a\": 1}') AS STRING)",
- null,
+ "{a=1}",
STRING())
// A bounded CHAR/VARCHAR target trims a longer value,
and CHAR pads a
// shorter one to its fixed width, the same as a
regular cast into it.
@@ -354,6 +360,25 @@ public class CastFunctionITCase extends
BuiltInFunctionTestBase {
"TRY_CAST(PARSE_JSON('\"ab\"') AS CHAR(5))",
"ab ",
CHAR(5))
+ // A nested variant null renders as NULL and a nested
container renders in
+ // full, with strings unquoted, like a regular ARRAY
or MAP to string cast.
+ .testResult(
+ lit("[\"a\", null,
1]").parseJson().cast(STRING()),
+ "CAST(PARSE_JSON('[\"a\", null, 1]') AS
STRING)",
+ "[a, NULL, 1]",
+ STRING().notNull())
+ .testResult(
+ lit("{\"k\": [\"a\",
\"b\"]}").parseJson().cast(STRING()),
+ "CAST(PARSE_JSON('{\"k\": [\"a\", \"b\"]}') AS
STRING)",
+ "{k=[a, b]}",
+ STRING().notNull())
+ // A container renders in full and is then trimmed to
a bounded target, the
+ // same as any other value longer than the target.
+ .testResult(
+ lit("[1, 2, 3]").parseJson().cast(VARCHAR(5)),
+ "CAST(PARSE_JSON('[1, 2, 3]') AS VARCHAR(5))",
+ "[1, 2",
+ VARCHAR(5).notNull())
// A variant holding a JSON null casts to SQL NULL,
not to the text 'null'.
// The length of that text must not be checked against
the target either.
.testResult(
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
index 274626557d1..440ff8a5826 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRulesTest.java
@@ -1847,11 +1847,32 @@ class CastRulesTest {
VARIANT(),
VARIANT_BUILDER.of(INVALID_UTF8),
TableRuntimeException.class)
- // an object or an array has no scalar rendering and
fails the cast
- .fail(VARIANT(), VARIANT_ARRAY,
TableRuntimeException.class)
- .fail(VARIANT(), VARIANT_OBJECT,
TableRuntimeException.class)
- // printing is not a cast and cannot fail, so it
renders JSON instead,
- // which leaves a stored string quoted
+ // a byte value nested in a container is rendered
unbounded, so its error
+ // names STRING rather than the container's target;
the same holds for an
+ // object field value
+ .fail(
+ VARIANT(),
+ VARIANT_BUILDER
+ .array()
+ .add(VARIANT_BUILDER.of(INVALID_UTF8))
+ .build(),
+ TableRuntimeException.class,
+ "binary value to STRING")
+ .fail(
+ VARIANT(),
+ VARIANT_BUILDER
+ .object()
+ .add("k",
VARIANT_BUILDER.of(INVALID_UTF8))
+ .build(),
+ TableRuntimeException.class,
+ "binary value to STRING")
+ // an object or an array has no scalar form, so it
renders like a regular
+ // ARRAY or MAP to string cast, with strings unquoted
and a nested null
+ // shown as NULL
+ .fromCase(VARIANT(), VARIANT_ARRAY, fromString("[1,
two, FALSE, NULL]"))
+ .fromCase(VARIANT(), VARIANT_OBJECT,
fromString("{k=[1, 2]}"))
+ // printing renders every variant as JSON instead, so
a nested string is
+ // quoted and a null is the JSON null
.fromCasePrinting(
VARIANT(), VARIANT_ARRAY,
fromString("[1,\"two\",false,null]"))
.fromCasePrinting(VARIANT(), VARIANT_OBJECT,
fromString("{\"k\":[1,2]}"))
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java
index f4920a4d353..ddd86cdda64 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/VariantCastUtils.java
@@ -263,36 +263,74 @@ public final class VariantCastUtils {
}
/**
- * Casts a scalar {@code VARIANT} to a character string, rendering the
value the way a regular
- * SQL cast of the stored kind would. A value longer than {@code
targetLength} is trimmed, and a
- * {@code CHAR} target pads a shorter value to its fixed width. Both are
measured in code
- * points, so a character outside the BMP fills a single position even
though it occupies two
- * UTF-16 units.
- *
- * <p>A stored binary value has to be well-formed UTF-8, since a character
string cannot carry
- * bytes that no character maps to. Invalid input is rejected rather than
decoded into {@code
- * U+FFFD}, which would silently substitute a character the value never
held.
+ * Casts a {@code VARIANT} to a character string, rendering the stored
kind the way a regular
+ * SQL cast would. An object or array renders like a {@code MAP} or {@code
ARRAY} cast, an array
+ * as {@code [e1, e2]} and an object as {@code {k1=v1, k2=v2}}, with a
nested null shown as
+ * {@code NULL}. Strings are never quoted, at any depth; this is a SQL
rendering, not JSON, so
+ * use {@code JSON_STRING} for the JSON form. A value longer than {@code
targetLength} is
+ * trimmed and a {@code CHAR} target pads a shorter one, both counted in
code points rather than
+ * UTF-16 units. A binary value must be well-formed UTF-8, and is rejected
rather than decoded
+ * into {@code U+FFFD}.
*
* @param sessionZone the session time zone, applied to a {@code
TIMESTAMP_LTZ} value
*/
public static BinaryStringData toStringValue(
Variant variant, TimeZone sessionZone, int targetLength, boolean
charTarget) {
- final String value = getVariantTypeAsString(variant, sessionZone,
targetLength, charTarget);
+ final String value = renderValue(variant, sessionZone, targetLength,
charTarget);
// numChars and substring both count code points, so a character
outside the BMP fills one
// position rather than the two UTF-16 units it occupies.
- final BinaryStringData result = BinaryStringData.fromString(value);
- final int length = result.numChars();
- if (length > targetLength) {
- return result.substring(0, targetLength);
+ return variantKey(value, targetLength, charTarget);
+ }
+
+ /**
+ * Renders a variant as a character string. An array becomes {@code [e1,
e2]} and an object
+ * becomes {@code {k1=v1, k2=v2}}, matching how a regular {@code ARRAY} or
{@code MAP} casts to
+ * a string. Elements and field values recurse through the same rendering,
so a string stays
+ * unquoted at every depth. A scalar renders like a regular cast of its
stored kind.
+ */
+ private static String renderValue(
+ final Variant variant,
+ final TimeZone sessionZone,
+ final int targetLength,
+ final boolean charTarget) {
+ if (variant.isArray()) {
+ final int size = variant.getArraySize();
+ final StringBuilder sb = new StringBuilder();
+ sb.append('[');
+ for (int i = 0; i < size; i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ sb.append(renderElement(variant.getElement(i), sessionZone));
+ }
+ return sb.append(']').toString();
}
- if (charTarget && length < targetLength) {
- return BinaryStringDataUtil.concat(
- result, BinaryStringData.blankString(targetLength -
length));
+ if (variant.isObject()) {
+ final StringBuilder sb = new StringBuilder();
+ sb.append('{');
+ boolean first = true;
+ for (final String fieldName : variant.getFieldNames()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append(fieldName)
+ .append('=')
+ .append(renderElement(variant.getField(fieldName),
sessionZone));
+ }
+ return sb.append('}').toString();
}
- return result;
+ return renderScalar(variant, sessionZone, targetLength, charTarget);
}
- private static String getVariantTypeAsString(
+ /** Renders one array element or object field value; a nested null shows
as {@code NULL}. */
+ private static String renderElement(final Variant element, final TimeZone
sessionZone) {
+ return element.isNull()
+ ? "NULL"
+ : renderValue(element, sessionZone, Integer.MAX_VALUE, false);
+ }
+
+ private static String renderScalar(
final Variant variant,
final TimeZone sessionZone,
final int targetLength,
@@ -324,6 +362,7 @@ public final class VariantCastUtils {
final int invalidAt =
StringUtf8Utils.firstInvalidUtf8ByteIndex(utf8, 0,
utf8.length);
if (invalidAt >= 0) {
+ final String targetDescription =
characterTarget(targetLength, charTarget);
throw new TableRuntimeException(
String.format(
"Cannot cast the VARIANT binary value to
%s because it is not "
@@ -331,9 +370,7 @@ public final class VariantCastUtils {
+ "of %d. Cast to BYTES to inspect
the raw value, or "
+ "wrap that in MAKE_VALID_UTF8 to
replace every "
+ "invalid byte with the U+FFFD
replacement character.",
- characterTarget(targetLength, charTarget),
- invalidAt,
- utf8.length));
+ targetDescription, invalidAt,
utf8.length));
}
value = new String(utf8, StandardCharsets.UTF_8);
break;
@@ -359,13 +396,14 @@ public final class VariantCastUtils {
case NULL:
// Only reachable for a NOT NULL target. A nullable target
maps a null-valued
// variant to SQL NULL before this method is called.
+ final String targetDescription = characterTarget(targetLength,
charTarget);
throw new TableRuntimeException(
String.format(
"Cannot cast a VARIANT null value to %s
because the target does not "
+ "accept NULL.",
- characterTarget(targetLength, charTarget)));
+ targetDescription));
default:
- // An object or array has no scalar rendering.
+ // Any remaining kind has no scalar string rendering;
JSON_STRING serializes it.
throw new TableRuntimeException(
String.format(
"Cannot cast a VARIANT %s value to a character
string. Use the "
@@ -376,7 +414,13 @@ public final class VariantCastUtils {
}
private static String characterTarget(int targetLength, boolean
charTarget) {
- return String.format("%s(%d)", charTarget ? "CHAR" : "VARCHAR",
targetLength);
+ if (charTarget) {
+ return String.format("%s(%d)", "CHAR", targetLength);
+ }
+ if (targetLength == Integer.MAX_VALUE) {
+ return "STRING";
+ }
+ return String.format("%s(%d)", "VARCHAR", targetLength);
}
private static Number numeric(Variant variant, String targetType) {