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 4ad8e8372f2 [FLINK-37926][table] Support casting from VARIANT to ROW 
and STRUCTURED
4ad8e8372f2 is described below

commit 4ad8e8372f28a8aeac2b4b28077bfed3049e9b7a
Author: Ramin Gharib <[email protected]>
AuthorDate: Mon Sep 7 10:30:06 2026 +0200

    [FLINK-37926][table] Support casting from VARIANT to ROW and STRUCTURED
    
    This closes #29092.
---
 docs/content.zh/docs/sql/reference/data-types.md   |  29 ++-
 docs/content/docs/sql/reference/data-types.md      |  29 ++-
 .../types/logical/utils/LogicalTypeCasts.java      |   5 +
 .../flink/table/types/LogicalTypeCastsTest.java    |  24 +++
 .../AbstractVariantToConstructedCastRule.java      |   6 +-
 .../functions/casting/CastRuleProvider.java        |   1 +
 .../functions/casting/VariantToRowCastRule.java    | 234 +++++++++++++++++++++
 .../planner/functions/CastFunctionITCase.java      | 109 ++++++++++
 .../functions/casting/CastRuleProviderTest.java    |  15 ++
 .../planner/functions/casting/CastRulesTest.java   | 187 +++++++++++++++-
 .../table/runtime/functions/VariantCastUtils.java  |  10 +
 11 files changed, 643 insertions(+), 6 deletions(-)

diff --git a/docs/content.zh/docs/sql/reference/data-types.md 
b/docs/content.zh/docs/sql/reference/data-types.md
index b1e938567f2..61363906c27 100644
--- a/docs/content.zh/docs/sql/reference/data-types.md
+++ b/docs/content.zh/docs/sql/reference/data-types.md
@@ -1614,6 +1614,33 @@ CAST(m AS ARRAY<STRING>)   -- ['1', 'a'], a 
heterogeneous array still renders ea
 CAST(m AS ARRAY<VARIANT>)  -- [1, "a"] as variants, one level shredded
 ```
 
+A variant object casts to `ROW` or `STRUCTURED`, which likewise imposes a 
schema on it. The variant
+must be an object, otherwise the cast fails. Each field is itself a `VARIANT`, 
so it casts to its
+declared type by the same rules, recursively. Fields match by name and name 
matching is case
+sensitive. A `ROW` declared without field names uses the default names `f0`, 
`f1`, and so on, which
+must then be present in the object.
+
+- A field absent from the object fails the cast, whether the target field is 
nullable or not.
+- A field present but set to a variant null maps to SQL `NULL` when the field 
is nullable and fails
+  the cast when the target is `NOT NULL`.
+- Object fields the target does not name are dropped, so the row is a 
projection.
+- A `ROW` or `STRUCTURED` whose fields are `VARIANT` is the identity on those 
fields: it shreds one
+  level and keeps the rest semi-structured. A field set to a variant null 
stays a variant null
+  rather than becoming SQL `NULL`.
+
+If any field cast fails, the whole cast fails, and `TRY_CAST` returns `NULL` 
for the entire value
+rather than a partial result.
+
+The following examples use `o` for `PARSE_JSON('{"id": 7, "name": "ada", 
"email": null}')`:
+
+```sql
+CAST(o AS ROW<`id` INT, `name` STRING>)         -- (7, 'ada')
+CAST(o AS ROW<`name` STRING, `id` INT>)         -- ('ada', 7), the order of 
target fields is free
+CAST(o AS ROW<`id` INT, `email` STRING>)        -- (7, NULL), a field present 
as a variant null maps to NULL
+CAST(o AS ROW<`id` INT, `phone` STRING>)        -- fails, the field 'phone' is 
not present in the VARIANT
+CAST(o AS ROW<`id` VARIANT, `email` VARIANT>)   -- (7, null), each field kept 
as a variant, the variant null preserved
+```
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1828,7 +1855,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   |   N   
|      N       |   N   |     Y     |    N     |
+| `VARIANT`                              |                   N                 
  |                    !                     |     !     |     !     |     !    
 |     !      |     !     |    !     |    !    |    !     |   !    |   N    |   
   !      |        !        |     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 442536c212e..0c55f7eb9b8 100644
--- a/docs/content/docs/sql/reference/data-types.md
+++ b/docs/content/docs/sql/reference/data-types.md
@@ -1622,6 +1622,33 @@ CAST(m AS ARRAY<STRING>)   -- ['1', 'a'], a 
heterogeneous array still renders ea
 CAST(m AS ARRAY<VARIANT>)  -- [1, "a"] as variants, one level shredded
 ```
 
+A variant object casts to `ROW` or `STRUCTURED`, which likewise imposes a 
schema on it. The variant
+must be an object, otherwise the cast fails. Each field is itself a `VARIANT`, 
so it casts to its
+declared type by the same rules, recursively. Fields match by name and name 
matching is case
+sensitive. A `ROW` declared without field names uses the default names `f0`, 
`f1`, and so on, which
+must then be present in the object.
+
+- A field absent from the object fails the cast, whether the target field is 
nullable or not.
+- A field present but set to a variant null maps to SQL `NULL` when the field 
is nullable and fails
+  the cast when the target is `NOT NULL`.
+- Object fields the target does not name are dropped, so the row is a 
projection.
+- A `ROW` or `STRUCTURED` whose fields are `VARIANT` is the identity on those 
fields: it shreds one
+  level and keeps the rest semi-structured. A field set to a variant null 
stays a variant null
+  rather than becoming SQL `NULL`.
+
+If any field cast fails, the whole cast fails, and `TRY_CAST` returns `NULL` 
for the entire value
+rather than a partial result.
+
+The following examples use `o` for `PARSE_JSON('{"id": 7, "name": "ada", 
"email": null}')`:
+
+```sql
+CAST(o AS ROW<`id` INT, `name` STRING>)         -- (7, 'ada')
+CAST(o AS ROW<`name` STRING, `id` INT>)         -- ('ada', 7), the order of 
target fields is free
+CAST(o AS ROW<`id` INT, `email` STRING>)        -- (7, NULL), a field present 
as a variant null maps to NULL
+CAST(o AS ROW<`id` INT, `phone` STRING>)        -- fails, the field 'phone' is 
not present in the VARIANT
+CAST(o AS ROW<`id` VARIANT, `email` VARIANT>)   -- (7, null), each field kept 
as a variant, the variant null preserved
+```
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1837,7 +1864,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   |   N   
|      N       |   N   |     Y     |    N     |
+| `VARIANT`                              |                   N                 
  |                    !                     |     !     |     !     |     !    
 |     !      |     !     |    !     |    !    |    !     |   !    |   N    |   
   !      |        !        |     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-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
index 0e3e6b38e7b..8e6d1a47ce0 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/logical/utils/LogicalTypeCasts.java
@@ -685,6 +685,11 @@ public final class LogicalTypeCasts {
             // array cast rule; a per-element mismatch fails there, not here.
             return allowExplicit
                     && supportsCasting(sourceType, ((ArrayType) 
targetType).getElementType(), true);
+        } else if (sourceRoot == VARIANT && (targetRoot == ROW || targetRoot 
== STRUCTURED_TYPE)) {
+            // A variant object casts to ROW or STRUCTURED when VARIANT casts 
to every field type.
+            return allowExplicit
+                    && targetType.getChildren().stream()
+                            .allMatch(field -> supportsCasting(sourceType, 
field, true));
         } else if (sourceRoot == RAW
                         && !targetType.is(BINARY_STRING)
                         && !targetType.is(CHARACTER_STRING)
diff --git 
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
 
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
index ac293be2a25..75a99bc5e22 100644
--- 
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
+++ 
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/LogicalTypeCastsTest.java
@@ -60,6 +60,7 @@ import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
 import java.util.Arrays;
+import java.util.List;
 import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -308,6 +309,29 @@ class LogicalTypeCastsTest {
                                         
YearMonthIntervalType.YearMonthResolution.MONTH)),
                         false,
                         false),
+                // A variant object casts to ROW or STRUCTURED when every 
field is castable; an
+                // empty
+                // row is vacuously castable and matching is by name
+                Arguments.of(new VariantType(), new RowType(List.of()), false, 
true),
+                Arguments.of(
+                        new VariantType(),
+                        new RowType(
+                                List.of(
+                                        new RowField("f0", new IntType()),
+                                        new RowField("f1", 
VarCharType.STRING_TYPE))),
+                        false,
+                        true),
+                Arguments.of(
+                        new VariantType(),
+                        new RowType(
+                                List.of(
+                                        new RowField(
+                                                "f0",
+                                                new YearMonthIntervalType(
+                                                        
YearMonthIntervalType.YearMonthResolution
+                                                                .MONTH)))),
+                        false,
+                        false),
                 // MULTISET has no variant counterpart and stays unsupported
                 Arguments.of(
                         new VariantType(), new 
MultisetType(VarCharType.STRING_TYPE), false, false),
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/AbstractVariantToConstructedCastRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/AbstractVariantToConstructedCastRule.java
index f003b129ec2..e6d924617f3 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/AbstractVariantToConstructedCastRule.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/AbstractVariantToConstructedCastRule.java
@@ -30,9 +30,9 @@ import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.met
  * a shape check at each level, so the recursion bottoms out at the same 
scalar cast the primitive
  * and string rules perform and no new leaf semantics are introduced.
  *
- * <p>A constructed cast can always fail, on a shape mismatch, an unreadable 
leaf, or a missing
- * {@code NOT NULL} field, so {@code TRY_CAST} wraps the whole value and 
returns {@code NULL} for
- * any failure rather than a partial result.
+ * <p>A constructed cast can always fail, on a shape mismatch, an unreadable 
leaf, a missing field,
+ * or a VARIANT {@code null} in a {@code NOT NULL} position, so {@code 
TRY_CAST} wraps the whole
+ * value and returns {@code NULL} for any failure rather than a partial result.
  */
 abstract class AbstractVariantToConstructedCastRule<OUT>
         extends AbstractNullAwareCodeGeneratorCastRule<Variant, OUT> {
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java
index 99fa9b63f2a..d16012b0afe 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java
@@ -99,6 +99,7 @@ public class CastRuleProvider {
                 .addRule(VariantToStringCastRule.INSTANCE)
                 .addRule(VariantToPrimitiveCastRule.INSTANCE)
                 .addRule(VariantToArrayCastRule.INSTANCE)
+                .addRule(VariantToRowCastRule.INSTANCE)
                 // Bitmap rules
                 .addRule(BitmapToStringCastRule.INSTANCE)
                 .addRule(BitmapToBinaryCastRule.INSTANCE)
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToRowCastRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToRowCastRule.java
new file mode 100644
index 00000000000..ade8e61f128
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToRowCastRule.java
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.functions.casting;
+
+import org.apache.flink.table.api.TableRuntimeException;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.binary.BinaryRowData;
+import org.apache.flink.table.data.writer.BinaryRowWriter;
+import org.apache.flink.table.planner.codegen.CodeGeneratorContext;
+import org.apache.flink.table.runtime.functions.VariantCastUtils;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+import org.apache.flink.table.types.logical.utils.LogicalTypeChecks;
+import org.apache.flink.types.variant.Variant;
+
+import java.util.List;
+
+import static org.apache.flink.table.planner.codegen.CodeGenUtils.className;
+import static org.apache.flink.table.planner.codegen.CodeGenUtils.newName;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.binaryWriterWriteField;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.binaryWriterWriteNull;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.constructorCall;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.methodCall;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.staticCall;
+import static 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.strLiteral;
+
+/**
+ * {@link LogicalTypeRoot#VARIANT} to {@link LogicalTypeRoot#ROW} and {@link
+ * LogicalTypeRoot#STRUCTURED_TYPE} cast rule.
+ *
+ * <p>The variant must be an object, otherwise the cast fails. Fields match 
<b>by name</b> rather
+ * than by position, because a VARIANT object is unordered. Name matching is 
case sensitive. A
+ * target field absent from the object fails the cast. A field present but set 
to a VARIANT null
+ * maps to SQL {@code NULL} when it is nullable and fails the cast when it is 
{@code NOT NULL}. A
+ * {@link LogicalTypeRoot#VARIANT} target field is the exception: there the 
field cast is the
+ * identity, so a VARIANT null is kept as a variant null rather than 
downgraded to SQL {@code NULL}.
+ * Object fields the target does not name are dropped, so the row is a 
projection. A {@code
+ * STRUCTURED} target shares the {@code RowData} representation and is served 
by the same rule.
+ */
+class VariantToRowCastRule extends 
AbstractVariantToConstructedCastRule<RowData> {
+
+    static final VariantToRowCastRule INSTANCE = new VariantToRowCastRule();
+
+    private VariantToRowCastRule() {
+        
super(CastRulePredicate.builder().predicate(VariantToRowCastRule::matches).build());
+    }
+
+    private static boolean matches(LogicalType input, LogicalType target) {
+        if (!(input.is(LogicalTypeRoot.VARIANT)
+                && (target.is(LogicalTypeRoot.ROW)
+                        || target.is(LogicalTypeRoot.STRUCTURED_TYPE)))) {
+            return false;
+        }
+        return LogicalTypeChecks.getFieldTypes(target).stream()
+                .allMatch(fieldType -> CastRuleProvider.resolve(input, 
fieldType) != null);
+    }
+
+    /* Example generated code for ROW<`id` INT, `raw` VARIANT>. The typed `id` 
field runs the leaf
+    cast; the `raw` field runs the identity cast and is written as-is, so a 
VARIANT null is kept
+    rather than turned into SQL NULL:
+
+    org.apache.flink.table.runtime.functions.VariantCastUtils.requireObject(
+            variant$2, "ROW<`id` INT, `raw` VARIANT>");
+    writer$4.reset();
+    org.apache.flink.types.variant.Variant f0Value$5 = 
variant$2.getField("id");
+    if (f0Value$5 == null) {
+        throw new org.apache.flink.table.api.TableRuntimeException(
+                "Cannot cast the VARIANT object to ROW<`id` INT, `raw` 
VARIANT> because the field "
+                        + "'id' is not present in the VARIANT.");
+    } else {
+        if (f0Value$5.isNull()) {
+            writer$4.setNullAt(0);
+        } else {
+            result$6 =
+                    ((int) 
org.apache.flink.table.runtime.functions.VariantCastUtils.toIntegral(
+                            f0Value$5, -2147483648L, 2147483647L, "INTEGER"));
+            if (!isNull$7) {
+                writer$4.writeInt(0, result$6);
+            } else {
+                writer$4.setNullAt(0);
+            }
+        }
+    }
+    org.apache.flink.types.variant.Variant f1Value$8 = 
variant$2.getField("raw");
+    if (f1Value$8 == null) {
+        throw new org.apache.flink.table.api.TableRuntimeException(
+                "Cannot cast the VARIANT object to ROW<`id` INT, `raw` 
VARIANT> because the field "
+                        + "'raw' is not present in the VARIANT.");
+    } else {
+        writer$4.writeVariant(1, f1Value$8);
+    }
+    writer$4.complete();
+    result$3 = row$1.copy();
+
+    A NOT NULL typed field throws instead of the setNullAt(i) shown above when 
its value is a
+    VARIANT null.
+
+    */
+    @Override
+    protected String generateCodeBlockInternal(
+            CodeGeneratorCastRule.Context context,
+            String inputTerm,
+            String returnVariable,
+            LogicalType inputLogicalType,
+            LogicalType targetLogicalType) {
+        final List<String> fieldNames = 
LogicalTypeChecks.getFieldNames(targetLogicalType);
+        final List<LogicalType> fieldTypes = 
LogicalTypeChecks.getFieldTypes(targetLogicalType);
+        final CodeGeneratorContext codeGeneratorContext = 
context.getCodeGeneratorContext();
+
+        final String rowTerm = newName(codeGeneratorContext, "row");
+        final String writerTerm = newName(codeGeneratorContext, "writer");
+        context.declareClassField(
+                className(BinaryRowData.class),
+                rowTerm,
+                constructorCall(BinaryRowData.class, fieldTypes.size()));
+        context.declareClassField(
+                className(BinaryRowWriter.class),
+                writerTerm,
+                constructorCall(BinaryRowWriter.class, rowTerm));
+
+        final CastRuleUtils.CodeWriter writer =
+                new CastRuleUtils.CodeWriter()
+                        .stmt(
+                                staticCall(
+                                        VariantCastUtils.class,
+                                        "requireObject",
+                                        inputTerm,
+                                        
strLiteral(targetLogicalType.asSummaryString())))
+                        .stmt(methodCall(writerTerm, "reset"));
+
+        for (int i = 0; i < fieldTypes.size(); i++) {
+            final LogicalType fieldType = fieldTypes.get(i);
+            final String fieldName = fieldNames.get(i);
+            final String indexTerm = String.valueOf(i);
+            final String fieldTerm = newName(codeGeneratorContext, "f" + 
indexTerm + "Value");
+
+            // The field is guaranteed present and non-null here, since a 
missing field and a
+            // VARIANT
+            // null are handled below, so the inner cast is the plain 
VARIANT-to-field rule.
+            final CastCodeBlock codeBlock =
+                    CastRuleProvider.generateAlwaysNonNullCodeBlock(
+                            context, fieldTerm, inputLogicalType, fieldType);
+            final String writeField =
+                    binaryWriterWriteField(
+                            context, writerTerm, fieldType, indexTerm, 
codeBlock.getReturnTerm());
+            final String writeNull = binaryWriterWriteNull(writerTerm, 
fieldType, indexTerm);
+
+            // getField returns Java null for a field the object does not 
carry, which always fails
+            // the cast.
+            writer.declStmt(
+                    Variant.class,
+                    fieldTerm,
+                    methodCall(inputTerm, "getField", strLiteral(fieldName)));
+
+            if (fieldType.is(LogicalTypeRoot.VARIANT)) {
+                // The field cast is the identity, so a present VARIANT null 
field is a valid
+                // variant
+                // null and is kept as-is rather than downgraded to SQL NULL, 
matching
+                // ARRAY<VARIANT>
+                // and the top-level VARIANT cast. An absent field still fails 
the cast.
+                writer.ifStmt(
+                        fieldTerm + " == null",
+                        absentWriter ->
+                                absentWriter.throwStmt(
+                                        missingFieldError(fieldName, 
targetLogicalType)),
+                        presentWriter -> 
presentWriter.append(codeBlock).stmt(writeField));
+                continue;
+            }
+
+            // A field explicitly set to a VARIANT null returns a variant 
whose isNull() is true; it
+            // maps to SQL NULL for a nullable field and fails the cast for a 
NOT NULL one.
+            writer.ifStmt(
+                    fieldTerm + " == null",
+                    absentWriter ->
+                            
absentWriter.throwStmt(missingFieldError(fieldName, targetLogicalType)),
+                    presentWriter ->
+                            presentWriter.ifStmt(
+                                    methodCall(fieldTerm, "isNull"),
+                                    nullWriter -> {
+                                        if (fieldType.isNullable()) {
+                                            nullWriter.stmt(writeNull);
+                                        } else {
+                                            nullWriter.throwStmt(
+                                                    nullFieldError(fieldName, 
targetLogicalType));
+                                        }
+                                    },
+                                    valueWriter ->
+                                            valueWriter
+                                                    .append(codeBlock)
+                                                    .ifStmt(
+                                                            "!" + 
codeBlock.getIsNullTerm(),
+                                                            thenWriter ->
+                                                                    
thenWriter.stmt(writeField),
+                                                            elseWriter ->
+                                                                    
elseWriter.stmt(writeNull))));
+        }
+
+        writer.stmt(methodCall(writerTerm, "complete"))
+                .assignStmt(returnVariable, methodCall(rowTerm, "copy"));
+        return writer.toString();
+    }
+
+    private static String missingFieldError(String fieldName, LogicalType 
targetType) {
+        final String message =
+                String.format(
+                        "Cannot cast the VARIANT object to %s because the 
field '%s' is not present in the VARIANT.",
+                        targetType.asSummaryString(), fieldName);
+        return constructorCall(TableRuntimeException.class, 
strLiteral(message));
+    }
+
+    private static String nullFieldError(String fieldName, LogicalType 
targetType) {
+        final String message =
+                String.format(
+                        "Cannot cast the VARIANT object to %s because the 
field '%s' is a VARIANT null and the target does not accept NULL.",
+                        targetType.asSummaryString(), fieldName);
+        return constructorCall(TableRuntimeException.class, 
strLiteral(message));
+    }
+}
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 394827b5e60..2904c874f6c 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
@@ -158,6 +158,7 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
         final List<TestSetSpec> specs = new ArrayList<>();
         specs.addAll(variantPrimitiveCasts());
         specs.addAll(variantArrayCasts());
+        specs.addAll(variantRowCasts());
         return specs;
     }
 
@@ -566,6 +567,114 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
                                 BYTES().notNull()));
     }
 
+    private static List<TestSetSpec> variantRowCasts() {
+        final String obj = "{\"id\": 7, \"name\": \"ada\", \"active\": true}";
+        final String objNull = "{\"id\": 7, \"name\": null}";
+        final String nested =
+                "{\"user\": {\"id\": 1, \"since\": \"2020-01-01\"}, \"tags\": 
[\"x\", \"y\"]}";
+        return List.of(
+                TestSetSpec.forExpression("Cast a VARIANT produced by 
parseJson() to a ROW")
+                        .onFieldsWithData("unused")
+                        .andDataTypes(STRING())
+                        // ROW: fields match by name, order is free
+                        .testResult(
+                                lit(obj).parseJson()
+                                        .cast(ROW(FIELD("id", INT()), 
FIELD("name", STRING()))),
+                                "CAST(PARSE_JSON('" + obj + "') AS ROW<`id` 
INT, `name` STRING>)",
+                                Row.of(7, "ada"),
+                                ROW(FIELD("id", INT()), FIELD("name", 
STRING())).notNull())
+                        .testResult(
+                                lit(obj).parseJson()
+                                        .cast(ROW(FIELD("name", STRING()), 
FIELD("id", INT()))),
+                                "CAST(PARSE_JSON('" + obj + "') AS ROW<`name` 
STRING, `id` INT>)",
+                                Row.of("ada", 7),
+                                ROW(FIELD("name", STRING()), FIELD("id", 
INT())).notNull())
+                        // a field absent from the object fails the cast
+                        .testSqlRuntimeError(
+                                "CAST(PARSE_JSON('"
+                                        + obj
+                                        + "') AS ROW<`id` INT, `non-existing` 
STRING>)",
+                                TableRuntimeException.class,
+                                "is not present in the VARIANT")
+                        // a field present but set to a variant null maps to 
SQL NULL when nullable
+                        .testResult(
+                                lit(objNull)
+                                        .parseJson()
+                                        .cast(ROW(FIELD("id", INT()), 
FIELD("name", STRING()))),
+                                "CAST(PARSE_JSON('"
+                                        + objNull
+                                        + "') AS ROW<`id` INT, `name` 
STRING>)",
+                                Row.of(7, null),
+                                ROW(FIELD("id", INT()), FIELD("name", 
STRING())).notNull())
+                        // and fails when that field is NOT NULL
+                        .testSqlRuntimeError(
+                                "CAST(PARSE_JSON('"
+                                        + objNull
+                                        + "') AS ROW<`id` INT, `name` STRING 
NOT NULL>)",
+                                TableRuntimeException.class,
+                                "does not accept NULL")
+                        // extra object fields are dropped, so the row is a 
projection
+                        .testResult(
+                                lit(obj).parseJson().cast(ROW(FIELD("id", 
INT()))),
+                                "CAST(PARSE_JSON('" + obj + "') AS ROW<`id` 
INT>)",
+                                Row.of(7),
+                                ROW(FIELD("id", INT())).notNull())
+                        // an array is not an object
+                        .testTableApiRuntimeError(
+                                lit("[1, 2, 
3]").parseJson().cast(ROW(FIELD("id", INT()))),
+                                "requires an object")
+                        // ROW<VARIANT> shreds one level, keeping each field a 
variant that then
+                        // casts back unchanged
+                        .testResult(
+                                lit(obj).parseJson()
+                                        .cast(ROW(FIELD("id", VARIANT()), 
FIELD("name", VARIANT())))
+                                        .cast(ROW(FIELD("id", INT()), 
FIELD("name", STRING()))),
+                                "CAST(CAST(PARSE_JSON('"
+                                        + obj
+                                        + "') AS ROW<`id` VARIANT, `name` 
VARIANT>)"
+                                        + " AS ROW<`id` INT, `name` STRING>)",
+                                Row.of(7, "ada"),
+                                ROW(FIELD("id", INT()), FIELD("name", 
STRING())).notNull())
+                        // a variant null field is kept as a variant null, so 
casting it back to a
+                        // concrete nullable type yields SQL NULL
+                        .testResult(
+                                lit(objNull)
+                                        .parseJson()
+                                        .cast(ROW(FIELD("id", VARIANT()), 
FIELD("name", VARIANT())))
+                                        .cast(ROW(FIELD("id", INT()), 
FIELD("name", STRING()))),
+                                "CAST(CAST(PARSE_JSON('"
+                                        + objNull
+                                        + "') AS ROW<`id` VARIANT, `name` 
VARIANT>)"
+                                        + " AS ROW<`id` INT, `name` STRING>)",
+                                Row.of(7, null),
+                                ROW(FIELD("id", INT()), FIELD("name", 
STRING())).notNull())
+                        // the recursion composes for nested rows and arrays
+                        .testResult(
+                                lit(nested)
+                                        .parseJson()
+                                        .cast(
+                                                ROW(
+                                                        FIELD(
+                                                                "user",
+                                                                ROW(
+                                                                        
FIELD("id", INT()),
+                                                                        
FIELD("since", STRING()))),
+                                                        FIELD("tags", 
ARRAY(STRING())))),
+                                "CAST(PARSE_JSON('"
+                                        + nested
+                                        + "') AS ROW<`user` ROW<`id` INT, 
`since` STRING>,"
+                                        + " `tags` ARRAY<STRING>>)",
+                                Row.of(Row.of(1, "2020-01-01"), new String[] 
{"x", "y"}),
+                                ROW(
+                                                FIELD(
+                                                        "user",
+                                                        ROW(
+                                                                FIELD("id", 
INT()),
+                                                                FIELD("since", 
STRING()))),
+                                                FIELD("tags", ARRAY(STRING())))
+                                        .notNull()));
+    }
+
     private static List<TestSetSpec> allTypesBasic() {
         return Arrays.asList(
                 CastTestSpecBuilder.testCastTo(BOOLEAN())
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java
index fabe82749f4..19fcd689362 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/functions/casting/CastRuleProviderTest.java
@@ -157,4 +157,19 @@ class CastRuleProviderTest {
         // MULTISET has no variant counterpart
         assertThat(CastRuleProvider.exists(VARIANT, 
MULTISET(STRING()).getLogicalType())).isFalse();
     }
+
+    @Test
+    void testResolveVariantToRow() {
+        assertThat(CastRuleProvider.resolve(VARIANT, ROW(FIELD("f0", 
INT())).getLogicalType()))
+                .isSameAs(VariantToRowCastRule.INSTANCE);
+        // a structured target shares the ROW rule
+        assertThat(CastRuleProvider.resolve(VARIANT, STRUCTURED))
+                .isSameAs(VariantToRowCastRule.INSTANCE);
+
+        // a field with no variant counterpart makes the whole cast 
unresolvable
+        assertThat(
+                        CastRuleProvider.exists(
+                                VARIANT, ROW(FIELD("f0", 
MULTISET(STRING()))).getLogicalType()))
+                .isFalse();
+    }
 }
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 2ba4de50381..8ecf6556bb5 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
@@ -41,6 +41,7 @@ import org.apache.flink.table.types.DataType;
 import org.apache.flink.table.types.logical.StructuredType;
 import org.apache.flink.table.utils.DateTimeUtils;
 import org.apache.flink.types.bitmap.Bitmap;
+import org.apache.flink.types.variant.BinaryVariant;
 import org.apache.flink.types.variant.Variant;
 import org.apache.flink.types.variant.VariantBuilder;
 
@@ -261,6 +262,71 @@ class CastRulesTest {
 
     private static final Variant VARIANT_EMPTY_ARRAY = 
VARIANT_BUILDER.array().build();
 
+    /** {@code {"id": 7, "name": "ada", "active": true}}, the design's running 
object value. */
+    private static final Variant VARIANT_RECORD =
+            Variant.newBuilder()
+                    .object()
+                    .add("id", Variant.newBuilder().of(7))
+                    .add("name", Variant.newBuilder().of("ada"))
+                    .add("active", Variant.newBuilder().of(true))
+                    .build();
+
+    /**
+     * {@code {"id": 7, "email": null}}, an object with a field explicitly set 
to a VARIANT null.
+     */
+    private static final Variant VARIANT_RECORD_WITH_NULL =
+            Variant.newBuilder()
+                    .object()
+                    .add("id", Variant.newBuilder().of(7))
+                    .add("email", Variant.newBuilder().ofNull())
+                    .build();
+
+    /** {@code {"user": {"id": 1, "since": "2020-01-01"}, "tags": ["x", 
"y"]}}, a nested value. */
+    private static final Variant VARIANT_NESTED =
+            Variant.newBuilder()
+                    .object()
+                    .add(
+                            "user",
+                            Variant.newBuilder()
+                                    .object()
+                                    .add("id", Variant.newBuilder().of(1))
+                                    .add("since", 
Variant.newBuilder().of("2020-01-01"))
+                                    .build())
+                    .add(
+                            "tags",
+                            Variant.newBuilder()
+                                    .array()
+                                    .add(Variant.newBuilder().of("x"))
+                                    .add(Variant.newBuilder().of("y"))
+                                    .build())
+                    .build();
+
+    /** {@code {"f0": 7, "f1": "ada"}}, an object keyed by the default {@code 
ROW} field names. */
+    private static final Variant VARIANT_POSITIONAL_RECORD =
+            VARIANT_BUILDER
+                    .object()
+                    .add("f0", VARIANT_BUILDER.of(7))
+                    .add("f1", VARIANT_BUILDER.of("ada"))
+                    .build();
+
+    /**
+     * {@code {"a": 1, "b": 2, "c": "x", "d": ["p", "q"]}}, shaped for {@link 
#MY_STRUCTURED_TYPE}.
+     */
+    private static final Variant VARIANT_STRUCT_RECORD =
+            VARIANT_BUILDER
+                    .object()
+                    .add("a", VARIANT_BUILDER.of(1L))
+                    .add("b", VARIANT_BUILDER.of(2L))
+                    .add("c", VARIANT_BUILDER.of("x"))
+                    .add(
+                            "d",
+                            VARIANT_BUILDER
+                                    .array()
+                                    .add(VARIANT_BUILDER.of("p"))
+                                    .add(VARIANT_BUILDER.of("q"))
+                                    .build())
+                    .build();
+
     private static final DataType MY_STRUCTURED_TYPE =
             STRUCTURED(
                     MyStructuredType.class,
@@ -283,6 +349,13 @@ class CastRulesTest {
                                                     "d", 
ARRAY(STRING()).getLogicalType())))
                             .build());
 
+    // Rebuilds a variant object field at position 0, matching the form a ROW 
cast produces when it
+    // serializes each field with BinaryRowWriter.writeVariant and reads it 
back.
+    private static Variant rowFieldVariant(Variant fieldView) {
+        final BinaryVariant view = (BinaryVariant) fieldView;
+        return new BinaryVariant(view.getValue(), view.getMetadata());
+    }
+
     Stream<CastTestSpecBuilder> testCases() {
         return Stream.of(
                 CastTestSpecBuilder.testCastTo(TINYINT())
@@ -2072,7 +2145,119 @@ class CastRulesTest {
                                             
VARIANT_INT_ARRAY_WITH_NULL.getElement(0),
                                             
VARIANT_INT_ARRAY_WITH_NULL.getElement(1),
                                             
VARIANT_INT_ARRAY_WITH_NULL.getElement(2)
-                                        })));
+                                        })),
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("id", INT()), 
FIELD("name", STRING())))
+                        .fromCase(VARIANT(), null, null)
+                        .fromCase(
+                                VARIANT(), VARIANT_RECORD, 
GenericRowData.of(7, fromString("ada")))
+                        // an array or a scalar is not an object
+                        .fail(
+                                VARIANT(),
+                                VARIANT_INT_ARRAY,
+                                TableRuntimeException.class,
+                                "requires an object"),
+                // field order of the target is free, since matching is by name
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("name", STRING()), 
FIELD("id", INT())))
+                        .fromCase(
+                                VARIANT(), VARIANT_RECORD, 
GenericRowData.of(fromString("ada"), 7)),
+                // a field absent from the object fails the cast, nullable or 
not
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("id", INT()), 
FIELD("email", STRING())))
+                        .fail(
+                                VARIANT(),
+                                VARIANT_RECORD,
+                                TableRuntimeException.class,
+                                "is not present in the VARIANT"),
+                CastTestSpecBuilder.testCastTo(
+                                ROW(FIELD("id", INT()), FIELD("email", 
STRING().notNull())))
+                        .fail(
+                                VARIANT(),
+                                VARIANT_RECORD,
+                                TableRuntimeException.class,
+                                "is not present in the VARIANT"),
+                // a field present but set to a VARIANT null maps to NULL when 
nullable
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("id", INT()), 
FIELD("email", STRING())))
+                        .fromCase(VARIANT(), VARIANT_RECORD_WITH_NULL, 
GenericRowData.of(7, null)),
+                // and fails when the field is NOT NULL
+                CastTestSpecBuilder.testCastTo(
+                                ROW(FIELD("id", INT()), FIELD("email", 
STRING().notNull())))
+                        .fail(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                TableRuntimeException.class,
+                                "does not accept NULL"),
+                // extra object fields are dropped, so the row is a projection
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("id", INT())))
+                        .fromCase(VARIANT(), VARIANT_RECORD, 
GenericRowData.of(7)),
+                // a ROW<VARIANT> field is the identity: each field is kept as 
a variant, one level
+                // shredded
+                CastTestSpecBuilder.testCastTo(
+                                ROW(FIELD("id", VARIANT()), FIELD("name", 
VARIANT())))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD,
+                                GenericRowData.of(
+                                        
rowFieldVariant(VARIANT_RECORD.getField("id")),
+                                        
rowFieldVariant(VARIANT_RECORD.getField("name")))),
+                // a VARIANT target field keeps a variant null as a variant 
null, not SQL NULL
+                CastTestSpecBuilder.testCastTo(
+                                ROW(FIELD("id", VARIANT()), FIELD("email", 
VARIANT())))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                GenericRowData.of(
+                                        
rowFieldVariant(VARIANT_RECORD_WITH_NULL.getField("id")),
+                                        rowFieldVariant(
+                                                
VARIANT_RECORD_WITH_NULL.getField("email")))),
+                // a typed field beside a VARIANT field keeps the variant null 
only on the VARIANT
+                // side
+                CastTestSpecBuilder.testCastTo(ROW(FIELD("id", INT()), 
FIELD("email", VARIANT())))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                GenericRowData.of(
+                                        7,
+                                        rowFieldVariant(
+                                                
VARIANT_RECORD_WITH_NULL.getField("email")))),
+                // a ROW without declared field names uses the default names 
f0, f1, ...; matching
+                // is
+                // still by name, not by position
+                CastTestSpecBuilder.testCastTo(ROW(INT(), STRING()))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_POSITIONAL_RECORD,
+                                GenericRowData.of(7, fromString("ada")))
+                        // an object without the default names fails, position 
is never used
+                        .fail(
+                                VARIANT(),
+                                VARIANT_RECORD,
+                                TableRuntimeException.class,
+                                "is not present in the VARIANT"),
+                // a STRUCTURED target is served by the same rule, matching 
attributes to object
+                // fields by name
+                CastTestSpecBuilder.testCastTo(MY_STRUCTURED_TYPE)
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_STRUCT_RECORD,
+                                GenericRowData.of(
+                                        1L,
+                                        2L,
+                                        fromString("x"),
+                                        new GenericArrayData(
+                                                new Object[] {fromString("p"), 
fromString("q")}))),
+                // the recursion composes: a row of a row and an array with no 
special case
+                CastTestSpecBuilder.testCastTo(
+                                ROW(
+                                        FIELD(
+                                                "user",
+                                                ROW(FIELD("id", INT()), 
FIELD("since", STRING()))),
+                                        FIELD("tags", ARRAY(STRING()))))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_NESTED,
+                                GenericRowData.of(
+                                        GenericRowData.of(1, 
fromString("2020-01-01")),
+                                        new GenericArrayData(
+                                                new Object[] {fromString("x"), 
fromString("y")}))));
     }
 
     @TestFactory
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 ac8d550195c..f273575cdbe 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
@@ -74,6 +74,16 @@ public final class VariantCastUtils {
         throw wrongShape(variant, targetType, "an array");
     }
 
+    /**
+     * Fails when the variant is not an object, so a cast to {@code ROW}, 
{@code STRUCTURED}, or
+     * {@code MAP} reports a shape mismatch clearly. Only an object carries 
named fields.
+     */
+    public static void requireObject(Variant variant, String targetType) {
+        if (!variant.isObject()) {
+            throw wrongShape(variant, targetType, "an object");
+        }
+    }
+
     private static TableRuntimeException wrongShape(
             Variant variant, String targetType, String required) {
         return new TableRuntimeException(

Reply via email to