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 78b0df60e34 [FLINK-37926][table] Support casting from VARIANT to MAP
78b0df60e34 is described below

commit 78b0df60e341c7c0996b2bd9f511acf9e293a647
Author: Ramin Gharib <[email protected]>
AuthorDate: Mon Sep 7 12:11:24 2026 +0200

    [FLINK-37926][table] Support casting from VARIANT to MAP
    
    This closes #29118.
---
 docs/content.zh/docs/sql/reference/data-types.md   |  20 +-
 docs/content/docs/sql/reference/data-types.md      |  20 +-
 .../types/logical/utils/LogicalTypeCasts.java      |   8 +
 .../flink/table/types/LogicalTypeCastsTest.java    |  26 +++
 .../functions/casting/CastRuleProvider.java        |   1 +
 .../functions/casting/VariantToMapCastRule.java    | 208 +++++++++++++++++++++
 .../planner/functions/CastFunctionITCase.java      |  43 +++++
 .../functions/casting/CastRuleProviderTest.java    |  14 ++
 .../planner/functions/casting/CastRulesTest.java   |  88 ++++++++-
 .../table/runtime/functions/VariantCastUtils.java  |  18 ++
 10 files changed, 443 insertions(+), 3 deletions(-)

diff --git a/docs/content.zh/docs/sql/reference/data-types.md 
b/docs/content.zh/docs/sql/reference/data-types.md
index 61363906c27..9bc67dd733e 100644
--- a/docs/content.zh/docs/sql/reference/data-types.md
+++ b/docs/content.zh/docs/sql/reference/data-types.md
@@ -1641,6 +1641,24 @@ CAST(o AS ROW<`id` INT, `phone` STRING>)        -- 
fails, the field 'phone' is n
 CAST(o AS ROW<`id` VARIANT, `email` VARIANT>)   -- (7, null), each field kept 
as a variant, the variant null preserved
 ```
 
+A variant object also casts to `MAP<STRING, V>`, the schemaless read of an 
object. Each field name
+becomes a key and each value casts to `V` by the same rules, recursively. The 
key type must be a
+character string, since a variant object's keys are always strings, and a 
non-string key type is
+rejected at validation. This is the way to read an object whose keys are not 
known in advance.
+
+- A value present but set to a variant null maps to SQL `NULL` when `V` is 
nullable and fails the
+  cast when `V` is `NOT NULL`. An empty object casts to an empty map.
+- A `MAP<STRING, VARIANT>` is the identity on its values: it shreds one level 
and keeps each value a
+  variant, a variant null included.
+
+The following examples reuse `o` for `PARSE_JSON('{"id": 7, "name": "ada", 
"email": null}')`:
+
+```sql
+CAST(o AS MAP<STRING, STRING>)   -- {id=7, name=ada, email=NULL}, each value 
rendered like the scalar cast
+CAST(o AS MAP<STRING, VARIANT>)  -- values kept as variants, the variant null 
included
+CAST(o AS MAP<INT, STRING>)      -- fails at validation, a MAP key must be a 
character string
+```
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1855,7 +1873,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   |     Y     |    N     |
+| `VARIANT`                              |                   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 0c55f7eb9b8..8e43c2ec12e 100644
--- a/docs/content/docs/sql/reference/data-types.md
+++ b/docs/content/docs/sql/reference/data-types.md
@@ -1649,6 +1649,24 @@ CAST(o AS ROW<`id` INT, `phone` STRING>)        -- 
fails, the field 'phone' is n
 CAST(o AS ROW<`id` VARIANT, `email` VARIANT>)   -- (7, null), each field kept 
as a variant, the variant null preserved
 ```
 
+A variant object also casts to `MAP<STRING, V>`, the schemaless read of an 
object. Each field name
+becomes a key and each value casts to `V` by the same rules, recursively. The 
key type must be a
+character string, since a variant object's keys are always strings, and a 
non-string key type is
+rejected at validation. This is the way to read an object whose keys are not 
known in advance.
+
+- A value present but set to a variant null maps to SQL `NULL` when `V` is 
nullable and fails the
+  cast when `V` is `NOT NULL`. An empty object casts to an empty map.
+- A `MAP<STRING, VARIANT>` is the identity on its values: it shreds one level 
and keeps each value a
+  variant, a variant null included.
+
+The following examples reuse `o` for `PARSE_JSON('{"id": 7, "name": "ada", 
"email": null}')`:
+
+```sql
+CAST(o AS MAP<STRING, STRING>)   -- {id=7, name=ada, email=NULL}, each value 
rendered like the scalar cast
+CAST(o AS MAP<STRING, VARIANT>)  -- values kept as variants, the variant null 
included
+CAST(o AS MAP<INT, STRING>)      -- fails at validation, a MAP key must be a 
character string
+```
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1864,7 +1882,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   |     Y     |    N     |
+| `VARIANT`                              |                   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 8e6d1a47ce0..474e63b3670 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
@@ -69,6 +69,7 @@ import static 
org.apache.flink.table.types.logical.LogicalTypeRoot.FLOAT;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.INTEGER;
 import static 
org.apache.flink.table.types.logical.LogicalTypeRoot.INTERVAL_DAY_TIME;
 import static 
org.apache.flink.table.types.logical.LogicalTypeRoot.INTERVAL_YEAR_MONTH;
+import static org.apache.flink.table.types.logical.LogicalTypeRoot.MAP;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.NULL;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.RAW;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.ROW;
@@ -690,6 +691,13 @@ public final class LogicalTypeCasts {
             return allowExplicit
                     && targetType.getChildren().stream()
                             .allMatch(field -> supportsCasting(sourceType, 
field, true));
+        } else if (sourceRoot == VARIANT && targetRoot == MAP) {
+            // A variant object casts to MAP<STRING, V> when the key is a 
character string, since
+            // VARIANT object keys are always strings, and VARIANT casts to 
the value type V.
+            final List<LogicalType> mapChildren = targetType.getChildren();
+            return allowExplicit
+                    && mapChildren.get(0).is(CHARACTER_STRING)
+                    && supportsCasting(sourceType, mapChildren.get(1), 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 75a99bc5e22..f6022d3f2cb 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
@@ -34,6 +34,7 @@ import org.apache.flink.table.types.logical.FloatType;
 import org.apache.flink.table.types.logical.IntType;
 import org.apache.flink.table.types.logical.LocalZonedTimestampType;
 import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
 import org.apache.flink.table.types.logical.MultisetType;
 import org.apache.flink.table.types.logical.NullType;
 import org.apache.flink.table.types.logical.RawType;
@@ -332,6 +333,31 @@ class LogicalTypeCastsTest {
                                                                 .MONTH)))),
                         false,
                         false),
+                // A variant object casts to MAP<STRING, V> when the key is a 
character string and
+                // the value is castable; a non-string key is rejected
+                Arguments.of(
+                        new VariantType(),
+                        new MapType(VarCharType.STRING_TYPE, new IntType()),
+                        false,
+                        true),
+                Arguments.of(
+                        new VariantType(),
+                        new MapType(VarCharType.STRING_TYPE, new 
VariantType()),
+                        false,
+                        true),
+                Arguments.of(
+                        new VariantType(),
+                        new MapType(new IntType(), new CharType()),
+                        false,
+                        false),
+                Arguments.of(
+                        new VariantType(),
+                        new MapType(
+                                VarCharType.STRING_TYPE,
+                                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/CastRuleProvider.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/CastRuleProvider.java
index d16012b0afe..532c9bca994 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
@@ -100,6 +100,7 @@ public class CastRuleProvider {
                 .addRule(VariantToPrimitiveCastRule.INSTANCE)
                 .addRule(VariantToArrayCastRule.INSTANCE)
                 .addRule(VariantToRowCastRule.INSTANCE)
+                .addRule(VariantToMapCastRule.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/VariantToMapCastRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToMapCastRule.java
new file mode 100644
index 00000000000..71a8369de0d
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToMapCastRule.java
@@ -0,0 +1,208 @@
+/*
+ * 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.data.GenericMapData;
+import org.apache.flink.table.data.MapData;
+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.LogicalTypeFamily;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.utils.LogicalTypeChecks;
+import org.apache.flink.types.variant.Variant;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static 
org.apache.flink.table.planner.codegen.CodeGenUtils.boxedTypeTermForType;
+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.cast;
+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#MAP} cast rule.
+ *
+ * <p>The variant must be an object. Each field name becomes a key and each 
field value casts to the
+ * target value type by the full {@code VARIANT}-to-value rule, recursively. 
The key type must be a
+ * character string, because a variant object's keys are always strings; a 
non-string key type is
+ * rejected at validation. This is the only way to read an object whose keys 
are dynamic or unknown
+ * at query time, and {@code MAP<STRING, VARIANT>} is the schemaless read of 
an object.
+ *
+ * <p>A {@link LogicalTypeRoot#VARIANT} value type is the identity, so a 
VARIANT null value is kept
+ * as a variant null rather than downgraded to SQL {@code NULL}, matching 
{@code ARRAY<VARIANT>} and
+ * a {@code ROW} with {@code VARIANT} fields.
+ */
+class VariantToMapCastRule extends 
AbstractVariantToConstructedCastRule<MapData> {
+
+    static final VariantToMapCastRule INSTANCE = new VariantToMapCastRule();
+
+    private VariantToMapCastRule() {
+        super(
+                CastRulePredicate.builder()
+                        .predicate(
+                                (input, target) ->
+                                        input.is(LogicalTypeRoot.VARIANT)
+                                                && 
target.is(LogicalTypeRoot.MAP)
+                                                && ((MapType) target)
+                                                        .getKeyType()
+                                                        
.is(LogicalTypeFamily.CHARACTER_STRING)
+                                                && CastRuleProvider.resolve(
+                                                                input,
+                                                                ((MapType) 
target).getValueType())
+                                                        != null)
+                        .build());
+    }
+
+    /* Example generated code for MAP<`STRING`, `INT`>. Each field name 
becomes a key and each value
+    runs the leaf cast; a nullable value that is a VARIANT null is left as SQL 
NULL:
+
+    org.apache.flink.table.runtime.functions.VariantCastUtils.requireObject(
+            variant$2, "MAP<STRING, INT>");
+    java.util.List fieldNames$4 = variant$2.getFieldNames();
+    int size$5 = fieldNames$4.size();
+    java.util.Map map$6 = new java.util.HashMap(size$5);
+    for (int i = 0; i < size$5; i++) {
+        java.lang.String name$7 = (java.lang.String) fieldNames$4.get(i);
+        org.apache.flink.types.variant.Variant valueVariant$8 = 
variant$2.getField(name$7);
+        java.lang.Integer value$9 = null;
+        if (!valueVariant$8.isNull()) {
+            result$10 =
+                    ((int) 
org.apache.flink.table.runtime.functions.VariantCastUtils.toIntegral(
+                            valueVariant$8, -2147483648L, 2147483647L, 
"INTEGER"));
+            value$9 = result$10;
+        }
+        map$6.put(
+                
org.apache.flink.table.runtime.functions.VariantCastUtils.variantKey(
+                        name$7, 2147483647, false),
+                value$9);
+    }
+    result$3 = new org.apache.flink.table.data.GenericMapData(map$6);
+
+    A NOT NULL value type throws instead of leaving value$9 null for a VARIANT 
null value. A VARIANT
+    value type takes the identity cast unconditionally, so a VARIANT null is 
kept as a variant null.
+
+    */
+    @Override
+    protected String generateCodeBlockInternal(
+            CodeGeneratorCastRule.Context context,
+            String inputTerm,
+            String returnVariable,
+            LogicalType inputLogicalType,
+            LogicalType targetLogicalType) {
+        final MapType mapType = (MapType) targetLogicalType;
+        final LogicalType keyType = mapType.getKeyType();
+        final LogicalType valueType = mapType.getValueType();
+        final CodeGeneratorContext codeGeneratorContext = 
context.getCodeGeneratorContext();
+
+        final int keyLength = LogicalTypeChecks.getLength(keyType);
+        final boolean charKey = keyType.is(LogicalTypeRoot.CHAR);
+        final String valueTypeTerm = boxedTypeTermForType(valueType);
+
+        final String namesTerm = newName(codeGeneratorContext, "fieldNames");
+        final String sizeTerm = newName(codeGeneratorContext, "size");
+        final String mapTerm = newName(codeGeneratorContext, "map");
+        final String nameTerm = newName(codeGeneratorContext, "name");
+        final String valueVariantTerm = newName(codeGeneratorContext, 
"valueVariant");
+        final String valueTerm = newName(codeGeneratorContext, "value");
+
+        // The value is guaranteed non-null here, since a VARIANT null is 
handled below, so the
+        // inner
+        // cast is the plain VARIANT-to-value rule. A VARIANT null value maps 
to SQL NULL for a
+        // nullable value type, or fails the cast for a NOT NULL one.
+        final CastCodeBlock valueCast =
+                CastRuleProvider.generateAlwaysNonNullCodeBlock(
+                        context, valueVariantTerm, inputLogicalType, 
valueType);
+        final String putKey =
+                staticCall(
+                        VariantCastUtils.class,
+                        "variantKey",
+                        nameTerm,
+                        String.valueOf(keyLength),
+                        charKey);
+
+        return new CastRuleUtils.CodeWriter()
+                .stmt(
+                        staticCall(
+                                VariantCastUtils.class,
+                                "requireObject",
+                                inputTerm,
+                                
strLiteral(targetLogicalType.asSummaryString())))
+                .declStmt(className(List.class), namesTerm, 
methodCall(inputTerm, "getFieldNames"))
+                .declStmt(int.class, sizeTerm, methodCall(namesTerm, "size"))
+                .declStmt(className(Map.class), mapTerm, 
constructorCall(HashMap.class, sizeTerm))
+                .forStmt(
+                        sizeTerm,
+                        (index, loopWriter) -> {
+                            loopWriter
+                                    .declStmt(
+                                            String.class,
+                                            nameTerm,
+                                            cast(
+                                                    className(String.class),
+                                                    methodCall(namesTerm, 
"get", index)))
+                                    .declStmt(
+                                            Variant.class,
+                                            valueVariantTerm,
+                                            methodCall(inputTerm, "getField", 
nameTerm))
+                                    .declStmt(valueTypeTerm, valueTerm, 
"null");
+                            final String isPresent = "!" + 
methodCall(valueVariantTerm, "isNull");
+                            if (valueType.is(LogicalTypeRoot.VARIANT)) {
+                                // The value cast is the identity, so a 
present VARIANT null value
+                                // is
+                                // a valid variant null and is kept as-is 
rather than downgraded to
+                                // SQL NULL, matching ARRAY<VARIANT> and 
ROW<VARIANT>.
+                                loopWriter
+                                        .append(valueCast)
+                                        .assignStmt(valueTerm, 
valueCast.getReturnTerm());
+                            } else if (valueType.isNullable()) {
+                                loopWriter.ifStmt(
+                                        isPresent,
+                                        thenWriter ->
+                                                thenWriter
+                                                        .append(valueCast)
+                                                        .assignStmt(
+                                                                valueTerm,
+                                                                
valueCast.getReturnTerm()));
+                            } else {
+                                loopWriter.ifStmt(
+                                        isPresent,
+                                        thenWriter ->
+                                                thenWriter
+                                                        .append(valueCast)
+                                                        .assignStmt(
+                                                                valueTerm,
+                                                                
valueCast.getReturnTerm()),
+                                        elseWriter ->
+                                                elseWriter.throwStmt(
+                                                        "new 
org.apache.flink.table.api.TableRuntimeException(\"Cannot cast a VARIANT null 
object value to a NOT NULL map value type.\")"));
+                            }
+                            loopWriter.stmt(methodCall(mapTerm, "put", putKey, 
valueTerm));
+                        },
+                        codeGeneratorContext)
+                .assignStmt(returnVariable, 
constructorCall(GenericMapData.class, mapTerm))
+                .toString();
+    }
+}
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 2904c874f6c..b73054fbbf1 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
@@ -159,6 +159,7 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
         specs.addAll(variantPrimitiveCasts());
         specs.addAll(variantArrayCasts());
         specs.addAll(variantRowCasts());
+        specs.addAll(variantMapCasts());
         return specs;
     }
 
@@ -675,6 +676,48 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
                                         .notNull()));
     }
 
+    private static List<TestSetSpec> variantMapCasts() {
+        final String obj = "{\"id\": 7, \"name\": \"ada\", \"active\": true}";
+        final String objNull = "{\"id\": 7, \"email\": null}";
+        final String mixed = "{\"a\": 1, \"b\": \"x\"}";
+        return List.of(
+                TestSetSpec.forExpression("Cast a VARIANT produced by 
parseJson() to a MAP")
+                        .onFieldsWithData("unused")
+                        .andDataTypes(STRING())
+                        // MAP: each field name becomes a key, each value 
casts to V
+                        .testResult(
+                                lit(obj).parseJson().cast(MAP(STRING(), 
STRING())),
+                                "CAST(PARSE_JSON('" + obj + "') AS MAP<STRING, 
STRING>)",
+                                map(
+                                        entry("id", "7"),
+                                        entry("name", "ada"),
+                                        entry("active", "TRUE")),
+                                MAP(STRING(), STRING()).notNull())
+                        .testResult(
+                                lit("{}").parseJson().cast(MAP(STRING(), 
INT())),
+                                "CAST(PARSE_JSON('{}') AS MAP<STRING, INT>)",
+                                map(),
+                                MAP(STRING(), INT()).notNull())
+                        // a value present but set to a variant null maps to 
SQL NULL when nullable
+                        .testResult(
+                                lit(objNull).parseJson().cast(MAP(STRING(), 
STRING())),
+                                "CAST(PARSE_JSON('" + objNull + "') AS 
MAP<STRING, STRING>)",
+                                map(entry("id", "7"), entry("email", null)),
+                                MAP(STRING(), STRING()).notNull())
+                        // and fails when the value type is NOT NULL
+                        .testTableApiRuntimeError(
+                                lit(objNull).parseJson().cast(MAP(STRING(), 
STRING().notNull())),
+                                "NOT NULL map value type")
+                        // a value that is not an integer fails the cast
+                        .testTableApiRuntimeError(
+                                lit(mixed).parseJson().cast(MAP(STRING(), 
INT())),
+                                "does not change the type")
+                        // a non-string map key is rejected at validation
+                        .testTableApiValidationError(
+                                lit(obj).parseJson().cast(MAP(INT(), 
STRING())),
+                                "Unsupported cast"));
+    }
+
     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 19fcd689362..a743cf9ee55 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
@@ -36,6 +36,7 @@ import static org.apache.flink.table.api.DataTypes.DECIMAL;
 import static org.apache.flink.table.api.DataTypes.FIELD;
 import static org.apache.flink.table.api.DataTypes.INT;
 import static org.apache.flink.table.api.DataTypes.INTERVAL;
+import static org.apache.flink.table.api.DataTypes.MAP;
 import static org.apache.flink.table.api.DataTypes.MONTH;
 import static org.apache.flink.table.api.DataTypes.MULTISET;
 import static org.apache.flink.table.api.DataTypes.ROW;
@@ -172,4 +173,17 @@ class CastRuleProviderTest {
                                 VARIANT, ROW(FIELD("f0", 
MULTISET(STRING()))).getLogicalType()))
                 .isFalse();
     }
+
+    @Test
+    void testResolveVariantToMap() {
+        assertThat(CastRuleProvider.resolve(VARIANT, MAP(STRING(), 
INT()).getLogicalType()))
+                .isSameAs(VariantToMapCastRule.INSTANCE);
+        // the value recurses through the VARIANT rules, including the 
identity leaf
+        assertThat(CastRuleProvider.exists(VARIANT, MAP(STRING(), 
VARIANT()).getLogicalType()))
+                .isTrue();
+
+        // a non-string map key is rejected
+        assertThat(CastRuleProvider.exists(VARIANT, MAP(INT(), 
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 8ecf6556bb5..274626557d1 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
@@ -327,6 +327,24 @@ class CastRulesTest {
                                     .build())
                     .build();
 
+    /** {@code {"a": 1, "b": 2}}, an all-numeric object. */
+    private static final Variant VARIANT_NUM_OBJECT =
+            VARIANT_BUILDER
+                    .object()
+                    .add("a", VARIANT_BUILDER.of(1))
+                    .add("b", VARIANT_BUILDER.of(2))
+                    .build();
+
+    private static final Variant VARIANT_EMPTY_OBJECT = 
VARIANT_BUILDER.object().build();
+
+    /** {@code {"a": 1, "b": "x"}}, an object with a mixed integer and string 
value. */
+    private static final Variant VARIANT_MIXED_OBJECT =
+            VARIANT_BUILDER
+                    .object()
+                    .add("a", VARIANT_BUILDER.of(1))
+                    .add("b", VARIANT_BUILDER.of("x"))
+                    .build();
+
     private static final DataType MY_STRUCTURED_TYPE =
             STRUCTURED(
                     MyStructuredType.class,
@@ -2257,7 +2275,75 @@ class CastRulesTest {
                                 GenericRowData.of(
                                         GenericRowData.of(1, 
fromString("2020-01-01")),
                                         new GenericArrayData(
-                                                new Object[] {fromString("x"), 
fromString("y")}))));
+                                                new Object[] {fromString("x"), 
fromString("y")}))),
+                CastTestSpecBuilder.testCastTo(MAP(STRING(), STRING()))
+                        .fromCase(VARIANT(), null, null)
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD,
+                                mapData(
+                                        entry(fromString("id"), 
fromString("7")),
+                                        entry(fromString("name"), 
fromString("ada")),
+                                        entry(fromString("active"), 
fromString("TRUE"))))
+                        // an empty object casts to an empty map
+                        .fromCase(VARIANT(), VARIANT_EMPTY_OBJECT, mapData())
+                        // a value present but set to a variant null maps to 
SQL NULL when nullable
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                mapData(
+                                        entry(fromString("id"), 
fromString("7")),
+                                        entry(fromString("email"), null)))
+                        // a mixed object renders every value to STRING
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_MIXED_OBJECT,
+                                mapData(
+                                        entry(fromString("a"), 
fromString("1")),
+                                        entry(fromString("b"), 
fromString("x"))))
+                        // an array is not an object
+                        .fail(
+                                VARIANT(),
+                                VARIANT_INT_ARRAY,
+                                TableRuntimeException.class,
+                                "requires an object"),
+                // a NOT NULL value type rejects a variant null value
+                CastTestSpecBuilder.testCastTo(MAP(STRING(), 
STRING().notNull()))
+                        .fail(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                TableRuntimeException.class,
+                                "NOT NULL map value type"),
+                // MAP<STRING, VARIANT> keeps each value a variant, one level 
shredded
+                CastTestSpecBuilder.testCastTo(MAP(STRING(), VARIANT()))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_NUM_OBJECT,
+                                mapData(
+                                        entry(fromString("a"), 
VARIANT_NUM_OBJECT.getField("a")),
+                                        entry(fromString("b"), 
VARIANT_NUM_OBJECT.getField("b"))))
+                        // a VARIANT value keeps a variant null as a variant 
null, not SQL NULL
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_RECORD_WITH_NULL,
+                                mapData(
+                                        entry(
+                                                fromString("id"),
+                                                
VARIANT_RECORD_WITH_NULL.getField("id")),
+                                        entry(
+                                                fromString("email"),
+                                                
VARIANT_RECORD_WITH_NULL.getField("email")))),
+                CastTestSpecBuilder.testCastTo(MAP(STRING(), INT()))
+                        .fromCase(
+                                VARIANT(),
+                                VARIANT_NUM_OBJECT,
+                                mapData(entry(fromString("a"), 1), 
entry(fromString("b"), 2)))
+                        // a value that is not an integer fails the cast
+                        .fail(
+                                VARIANT(),
+                                VARIANT_MIXED_OBJECT,
+                                TableRuntimeException.class,
+                                "does not change the type"));
     }
 
     @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 f273575cdbe..f4920a4d353 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
@@ -84,6 +84,24 @@ public final class VariantCastUtils {
         }
     }
 
+    /**
+     * Renders an object field name as the map key, trimming or padding it to 
a bounded target the
+     * same way a regular cast into the key type would. Object keys are always 
strings, so no kind
+     * conversion happens here.
+     */
+    public static BinaryStringData variantKey(String name, int targetLength, 
boolean charTarget) {
+        final BinaryStringData key = BinaryStringData.fromString(name);
+        final int length = key.numChars();
+        if (length > targetLength) {
+            return key.substring(0, targetLength);
+        }
+        if (charTarget && length < targetLength) {
+            return BinaryStringDataUtil.concat(
+                    key, BinaryStringData.blankString(targetLength - length));
+        }
+        return key;
+    }
+
     private static TableRuntimeException wrongShape(
             Variant variant, String targetType, String required) {
         return new TableRuntimeException(

Reply via email to