This is an automated email from the ASF dual-hosted git repository.

snuyanzin 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 76e7898e484 [FLINK-37925][table] Support casting from `VARIANT` to 
primitive types
76e7898e484 is described below

commit 76e7898e484cb03aa6e030180310c4a941d41a83
Author: Ramin Gharib <[email protected]>
AuthorDate: Wed Jul 15 13:59:57 2026 +0200

    [FLINK-37925][table] Support casting from `VARIANT` to primitive types
---
 docs/content.zh/docs/sql/reference/data-types.md   |   8 +-
 docs/content/docs/sql/reference/data-types.md      |   8 +-
 .../strategies/CastInputTypeStrategy.java          |  10 +
 .../types/logical/utils/LogicalTypeCasts.java      |  44 ++++
 .../flink/table/types/LogicalTypeCastsTest.java    |  35 ++-
 .../functions/casting/CastRuleProvider.java        |   1 +
 .../casting/VariantToPrimitiveCastRule.java        | 237 +++++++++++++++++++++
 .../planner/functions/CastFunctionITCase.java      | 105 ++++++++-
 .../functions/casting/CastRuleProviderTest.java    |  28 +++
 .../planner/functions/casting/CastRulesTest.java   |  98 ++++++++-
 10 files changed, 569 insertions(+), 5 deletions(-)

diff --git a/docs/content.zh/docs/sql/reference/data-types.md 
b/docs/content.zh/docs/sql/reference/data-types.md
index 1cbc6f8bae6..6ad9cd02c7a 100644
--- a/docs/content.zh/docs/sql/reference/data-types.md
+++ b/docs/content.zh/docs/sql/reference/data-types.md
@@ -1515,6 +1515,12 @@ without requiring upfront schema definition. For 
example, if a new field is adde
 can be directly incorporated into the `VARIANT` data without modifying the 
table schema. This is
 particularly useful in dynamic environments where schemas may evolve over time.
 
+A primitive-valued `VARIANT` can be converted to a scalar type with `CAST` or 
`TRY_CAST`. Numeric
+targets are lenient: a variant holding any numeric value casts to any numeric 
type, so a JSON integer
+such as `PARSE_JSON('42')` casts to `INT` or `BIGINT`. Other targets require 
the stored value to be of
+the matching kind. When the value cannot be converted, `CAST` fails the job 
and `TRY_CAST` returns
+`NULL`. Use the `JSON_STRING` function to obtain the JSON string 
representation of a `VARIANT`.
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1729,7 +1735,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     |    N    |    N     |   N    |   N    |   
   N      |        N        |     N      |    N    |     N      |   N   |   N   
|      N       |   N   |     N     |    N     |
+| `VARIANT`                              |                   N                 
  |                    !                     |     !     |     !     |     !    
 |     !      |     !     |    !     |    !    |    !     |   !    |   N    |   
   !      |        !        |     N      |    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 a849ee5dac8..45ace31e365 100644
--- a/docs/content/docs/sql/reference/data-types.md
+++ b/docs/content/docs/sql/reference/data-types.md
@@ -1523,6 +1523,12 @@ without requiring upfront schema definition. For 
example, if a new field is adde
 can be directly incorporated into the `VARIANT` data without modifying the 
table schema. This is 
 particularly useful in dynamic environments where schemas may evolve over time.
 
+A primitive-valued `VARIANT` can be converted to a scalar type with `CAST` or 
`TRY_CAST`. Numeric 
+targets are lenient: a variant holding any numeric value casts to any numeric 
type, so a JSON integer 
+such as `PARSE_JSON('42')` casts to `INT` or `BIGINT`. Other targets require 
the stored value to be of 
+the matching kind. When the value cannot be converted, `CAST` fails the job 
and `TRY_CAST` returns 
+`NULL`. Use the `JSON_STRING` function to obtain the JSON string 
representation of a `VARIANT`.
+
 **Declaration**
 
 {{< tabs "25c30432-8460-441d-a036-9416d8202882" >}}
@@ -1738,7 +1744,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     |    N    |    N     |   N    |   N    |   
   N      |        N        |     N      |    N    |     N      |   N   |   N   
|      N       |   N   |     N     |    N     |
+| `VARIANT`                              |                   N                 
  |                    !                     |     !     |     !     |     !    
 |     !      |     !     |    !     |    !    |    !     |   !    |   N    |   
   !      |        !        |     N      |    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/inference/strategies/CastInputTypeStrategy.java
 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java
index fbd4bf188d7..3bd66e01cd5 100644
--- 
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java
+++ 
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/CastInputTypeStrategy.java
@@ -35,6 +35,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 
+import static 
org.apache.flink.table.types.logical.utils.LogicalTypeCasts.getUnsupportedCastHint;
 import static 
org.apache.flink.table.types.logical.utils.LogicalTypeCasts.supportsExplicitCast;
 
 /**
@@ -69,6 +70,15 @@ class CastInputTypeStrategy implements InputTypeStrategy {
             return Optional.of(argumentDataTypes);
         }
         if (!supportsExplicitCast(fromType, toType)) {
+            final Optional<String> hint = getUnsupportedCastHint(fromType, 
toType);
+            if (hint.isPresent()) {
+                return callContext.fail(
+                        throwOnFailure,
+                        "Unsupported cast from '%s' to '%s'. %s",
+                        fromType,
+                        toType,
+                        hint.get());
+            }
             return callContext.fail(
                     throwOnFailure, "Unsupported cast from '%s' to '%s'.", 
fromType, toType);
         }
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 a0afca2caa0..58f474ec0eb 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
@@ -37,6 +37,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.BiFunction;
 import java.util.function.BiPredicate;
@@ -79,6 +80,7 @@ import static 
org.apache.flink.table.types.logical.LogicalTypeRoot.TIME_WITHOUT_
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.TINYINT;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.VARBINARY;
 import static org.apache.flink.table.types.logical.LogicalTypeRoot.VARCHAR;
+import static org.apache.flink.table.types.logical.LogicalTypeRoot.VARIANT;
 import static 
org.apache.flink.table.types.logical.utils.LogicalTypeChecks.getDayPrecision;
 import static 
org.apache.flink.table.types.logical.utils.LogicalTypeChecks.getFractionalPrecision;
 import static 
org.apache.flink.table.types.logical.utils.LogicalTypeChecks.getLength;
@@ -646,6 +648,9 @@ public final class LogicalTypeCasts {
             // BITMAP can only be cast to BYTES (unbounded VARBINARY), because 
trimming or padding
             // would corrupt the serialized bitmap data.
             return allowExplicit && getLength(targetType) == 
VarBinaryType.MAX_LENGTH;
+        } else if (sourceRoot == VARIANT) {
+            // a VARIANT can only be explicitly cast to a supported scalar type
+            return allowExplicit && supportsVariantToScalarCast(targetType);
         }
 
         if (implicitCastingRules.get(targetRoot).contains(sourceRoot)) {
@@ -726,6 +731,45 @@ public final class LogicalTypeCasts {
         return false;
     }
 
+    private static boolean supportsVariantToScalarCast(LogicalType targetType) 
{
+        switch (targetType.getTypeRoot()) {
+            case BOOLEAN:
+            case TINYINT:
+            case SMALLINT:
+            case INTEGER:
+            case BIGINT:
+            case FLOAT:
+            case DOUBLE:
+            case DECIMAL:
+            case BINARY:
+            case VARBINARY:
+            case DATE:
+            case TIMESTAMP_WITHOUT_TIME_ZONE:
+            case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+                return true;
+            default:
+                // TIME has no counterpart in the Variant type model. 
CHARACTER_STRING is handled by
+                // the display-oriented VariantToStringCastRule and is 
intentionally not offered as
+                // a
+                // user-facing cast here.
+                return false;
+        }
+    }
+
+    /**
+     * Returns a hint pointing to the function that performs a conceptually 
related operation when
+     * an explicit cast is unsupported, or empty when no specific hint applies.
+     */
+    public static Optional<String> getUnsupportedCastHint(
+            LogicalType sourceType, LogicalType targetType) {
+        if (sourceType.is(VARIANT) && targetType.is(CHARACTER_STRING)) {
+            return Optional.of(
+                    "Use the JSON_STRING function to convert a VARIANT to its 
JSON string "
+                            + "representation.");
+        }
+        return Optional.empty();
+    }
+
     private static CastingRuleBuilder castTo(LogicalTypeRoot targetType) {
         return new CastingRuleBuilder(targetType);
     }
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 3d0dec23997..6fb3574b1f5 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.NullType;
 import org.apache.flink.table.types.logical.RawType;
 import org.apache.flink.table.types.logical.RowType;
@@ -46,6 +47,7 @@ import org.apache.flink.table.types.logical.TimestampType;
 import org.apache.flink.table.types.logical.TinyIntType;
 import org.apache.flink.table.types.logical.VarBinaryType;
 import org.apache.flink.table.types.logical.VarCharType;
+import org.apache.flink.table.types.logical.VariantType;
 import org.apache.flink.table.types.logical.YearMonthIntervalType;
 import org.apache.flink.table.types.logical.ZonedTimestampType;
 import org.apache.flink.table.types.logical.utils.LogicalTypeCasts;
@@ -57,6 +59,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;
@@ -262,7 +265,37 @@ class LogicalTypeCastsTest {
                         new RawType<>(Integer.class, IntSerializer.INSTANCE),
                         VarCharType.STRING_TYPE,
                         false,
-                        true));
+                        true),
+
+                // variant to scalar is explicit only
+                Arguments.of(new VariantType(), new BooleanType(), false, 
true),
+                Arguments.of(new VariantType(), new TinyIntType(), false, 
true),
+                Arguments.of(new VariantType(), new IntType(), false, true),
+                Arguments.of(new VariantType(), new BigIntType(), false, true),
+                Arguments.of(new VariantType(), new DoubleType(), false, true),
+                Arguments.of(new VariantType(), new DecimalType(10, 2), false, 
true),
+                Arguments.of(new VariantType(), new DateType(), false, true),
+                Arguments.of(new VariantType(), new TimestampType(), false, 
true),
+                Arguments.of(new VariantType(), new TimestampType(3), false, 
true),
+                Arguments.of(new VariantType(), new LocalZonedTimestampType(), 
false, true),
+                Arguments.of(new VariantType(), new 
LocalZonedTimestampType(9), false, true),
+                Arguments.of(
+                        new VariantType(),
+                        new VarBinaryType(VarBinaryType.MAX_LENGTH),
+                        false,
+                        true),
+                // variant identity cast is implicit
+                Arguments.of(new VariantType(), new VariantType(), true, true),
+                // TIME, character strings and constructed targets are not 
castable from variant
+                Arguments.of(new VariantType(), new TimeType(), false, false),
+                Arguments.of(new VariantType(), VarCharType.STRING_TYPE, 
false, false),
+                Arguments.of(new VariantType(), new ArrayType(new IntType()), 
false, false),
+                Arguments.of(new VariantType(), new RowType(List.of()), false, 
false),
+                Arguments.of(
+                        new VariantType(),
+                        new MapType(new IntType(), new CharType()),
+                        false,
+                        false));
     }
 
     @ParameterizedTest(name = "{index}: [From: {0}, To: {1}, Implicit: {2}, 
Explicit: {3}]")
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 b18b44e126e..133542ec6f5 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
@@ -97,6 +97,7 @@ public class CastRuleProvider {
                 .addRule(RowToRowCastRule.INSTANCE)
                 // Variant rules
                 .addRule(VariantToStringCastRule.INSTANCE)
+                .addRule(VariantToPrimitiveCastRule.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/VariantToPrimitiveCastRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java
new file mode 100644
index 00000000000..8a278f41498
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/functions/casting/VariantToPrimitiveCastRule.java
@@ -0,0 +1,237 @@
+/*
+ * 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.DecimalData;
+import org.apache.flink.table.data.TimestampData;
+import 
org.apache.flink.table.planner.functions.casting.CastRuleUtils.CodeWriter;
+import org.apache.flink.table.types.logical.DecimalType;
+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.math.BigDecimal;
+import java.util.Arrays;
+
+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.arrayLength;
+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.ternaryOperator;
+
+/**
+ * {@link LogicalTypeRoot#VARIANT} to primitive type cast rule.
+ *
+ * <p>Numeric targets are lenient and follow regular numeric cast semantics; 
other targets require
+ * the stored value to match the target kind. On a mismatch {@code CAST} fails 
and {@code TRY_CAST}
+ * returns {@code null}.
+ *
+ * <p>{@code CHARACTER_STRING} is handled by {@link VariantToStringCastRule}; 
{@code TIME} has no
+ * variant counterpart and is unsupported.
+ */
+class VariantToPrimitiveCastRule extends 
AbstractNullAwareCodeGeneratorCastRule<Variant, Object> {
+
+    static final VariantToPrimitiveCastRule INSTANCE = new 
VariantToPrimitiveCastRule();
+
+    private VariantToPrimitiveCastRule() {
+        super(
+                CastRulePredicate.builder()
+                        .predicate(
+                                (input, target) ->
+                                        input.is(LogicalTypeRoot.VARIANT)
+                                                && isSupportedTarget(target))
+                        .build());
+    }
+
+    private static boolean isSupportedTarget(LogicalType targetType) {
+        switch (targetType.getTypeRoot()) {
+            case BOOLEAN:
+            case TINYINT:
+            case SMALLINT:
+            case INTEGER:
+            case BIGINT:
+            case FLOAT:
+            case DOUBLE:
+            case DECIMAL:
+            case BINARY:
+            case VARBINARY:
+            case DATE:
+            case TIMESTAMP_WITHOUT_TIME_ZONE:
+            case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    @Override
+    public boolean canFail(LogicalType inputLogicalType, LogicalType 
targetLogicalType) {
+        return true;
+    }
+
+    /**
+     * Treats a variant that stores a JSON {@code null} as a {@code NULL} 
input, so it casts to SQL
+     * {@code NULL} instead of failing in the type-specific accessor. Only 
applied for a nullable
+     * target: a {@code NOT NULL} result cannot carry {@code NULL}, so a 
null-valued variant then
+     * fails as a regular type mismatch.
+     */
+    @Override
+    public CastCodeBlock generateCodeBlock(
+            CodeGeneratorCastRule.Context context,
+            String inputTerm,
+            String inputIsNullTerm,
+            LogicalType inputLogicalType,
+            LogicalType targetLogicalType) {
+        if (!targetLogicalType.isNullable()) {
+            return super.generateCodeBlock(
+                    context, inputTerm, inputIsNullTerm, inputLogicalType, 
targetLogicalType);
+        }
+        final String isNullTerm =
+                "(" + inputIsNullTerm + " || " + methodCall(inputTerm, 
"isNull") + ")";
+        return super.generateCodeBlock(
+                context, inputTerm, isNullTerm, inputLogicalType, 
targetLogicalType);
+    }
+
+    @Override
+    protected String generateCodeBlockInternal(
+            CodeGeneratorCastRule.Context context,
+            String inputTerm,
+            String returnVariable,
+            LogicalType inputLogicalType,
+            LogicalType targetLogicalType) {
+        final CodeWriter writer = new CastRuleUtils.CodeWriter();
+        switch (targetLogicalType.getTypeRoot()) {
+            case BOOLEAN:
+                writer.assignStmt(returnVariable, methodCall(inputTerm, 
"getBoolean"));
+                break;
+            case TINYINT:
+            case SMALLINT:
+            case INTEGER:
+            case BIGINT:
+            case FLOAT:
+            case DOUBLE:
+            case DECIMAL:
+                writer.assignStmt(returnVariable, numericExpression(inputTerm, 
targetLogicalType));
+                break;
+            case BINARY:
+            case VARBINARY:
+                generateToBytes(context, inputTerm, returnVariable, 
targetLogicalType, writer);
+                break;
+            case DATE:
+                writer.assignStmt(
+                        returnVariable,
+                        cast("int", methodCall(methodCall(inputTerm, 
"getDate"), "toEpochDay")));
+                break;
+            case TIMESTAMP_WITHOUT_TIME_ZONE:
+                writer.assignStmt(
+                        returnVariable,
+                        staticCall(
+                                TimestampData.class,
+                                "fromLocalDateTime",
+                                methodCall(inputTerm, "getDateTime")));
+                break;
+            case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+                writer.assignStmt(
+                        returnVariable,
+                        staticCall(
+                                TimestampData.class,
+                                "fromInstant",
+                                methodCall(inputTerm, "getInstant")));
+                break;
+            default:
+                throw new IllegalArgumentException(
+                        "Unsupported target type for casting from VARIANT: " + 
targetLogicalType);
+        }
+        return writer.toString();
+    }
+
+    /**
+     * Converts a numeric variant to the numeric {@code target} via the 
matching {@link Number}
+     * accessor, mirroring regular numeric cast semantics. A non-numeric 
variant raises {@link
+     * ClassCastException}, failing {@code CAST} and yielding {@code null} for 
{@code TRY_CAST}.
+     */
+    private static String numericExpression(String inputTerm, LogicalType 
target) {
+        final String number = cast(className(Number.class), 
methodCall(inputTerm, "get"));
+        if (!target.is(LogicalTypeRoot.DECIMAL)) {
+            return methodCall(number, numberAccessor(target));
+        }
+        final DecimalType decimalType = (DecimalType) target;
+        return staticCall(
+                DecimalData.class,
+                "fromBigDecimal",
+                constructorCall(BigDecimal.class, methodCall(number, 
"toString")),
+                decimalType.getPrecision(),
+                decimalType.getScale());
+    }
+
+    private static String numberAccessor(LogicalType target) {
+        switch (target.getTypeRoot()) {
+            case TINYINT:
+                return "byteValue";
+            case SMALLINT:
+                return "shortValue";
+            case INTEGER:
+                return "intValue";
+            case BIGINT:
+                return "longValue";
+            case FLOAT:
+                return "floatValue";
+            case DOUBLE:
+                return "doubleValue";
+            default:
+                throw new IllegalArgumentException(
+                        "Unsupported numeric target for casting from VARIANT: 
" + target);
+        }
+    }
+
+    private static void generateToBytes(
+            CodeGeneratorCastRule.Context context,
+            String inputTerm,
+            String returnVariable,
+            LogicalType targetLogicalType,
+            CodeWriter writer) {
+        final int targetLength = 
LogicalTypeChecks.getLength(targetLogicalType);
+        // Read the bytes once to avoid decoding the variant twice.
+        final String bytesTerm = newName(context.getCodeGeneratorContext(), 
"variantBytes");
+        writer.declStmt("byte[]", bytesTerm, methodCall(inputTerm, 
"getBytes"));
+        if (BinaryToBinaryCastRule.couldPad(targetLogicalType, targetLength)) {
+            // BINARY(n): pad or trim to the exact target length.
+            writer.assignStmt(
+                    returnVariable,
+                    ternaryOperator(
+                            arrayLength(bytesTerm) + " == " + targetLength,
+                            bytesTerm,
+                            staticCall(Arrays.class, "copyOf", bytesTerm, 
targetLength)));
+        } else if (BinaryToBinaryCastRule.couldTrim(targetLength)) {
+            // VARBINARY(n): trim only when longer than the target length.
+            writer.assignStmt(
+                    returnVariable,
+                    ternaryOperator(
+                            arrayLength(bytesTerm) + " <= " + targetLength,
+                            bytesTerm,
+                            staticCall(Arrays.class, "copyOf", bytesTerm, 
targetLength)));
+        } else {
+            writer.assignStmt(returnVariable, bytesTerm);
+        }
+    }
+}
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 5420ce1987f..cb41ad083c7 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
@@ -78,6 +78,7 @@ import static org.apache.flink.table.api.DataTypes.VARBINARY;
 import static org.apache.flink.table.api.DataTypes.VARCHAR;
 import static org.apache.flink.table.api.DataTypes.YEAR;
 import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.call;
 import static org.apache.flink.util.CollectionUtil.entry;
 import static org.apache.flink.util.CollectionUtil.map;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -132,9 +133,111 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
         specs.addAll(numericBounds());
         specs.addAll(constructedTypes());
         specs.addAll(bitmapCasts());
+        specs.addAll(variantCasts());
         return specs.stream();
     }
 
+    private static List<TestSetSpec> variantCasts() {
+        // A variant is produced with PARSE_JSON so that the source column 
stays a STRING literal;
+        // there is no VARIANT literal to feed a source column directly. A 
JSON integer is stored in
+        // the smallest integer type that fits (42 -> TINYINT), and numeric 
casts are lenient, so it
+        // still widens to INT.
+        return List.of(
+                TestSetSpec.forExpression("Cast a VARIANT produced by 
PARSE_JSON to a primitive")
+                        .onFieldsWithData("unused")
+                        .andDataTypes(STRING())
+                        .testResult(
+                                call("PARSE_JSON", "42").cast(INT()),
+                                "CAST(PARSE_JSON('42') AS INT)",
+                                42,
+                                INT().notNull())
+                        // Integer overflow wraps around (Java narrowing), 
like a regular numeric
+                        // cast.
+                        .testResult(
+                                call("PARSE_JSON", "40000").cast(SMALLINT()),
+                                "CAST(PARSE_JSON('40000') AS SMALLINT)",
+                                (short) -25536,
+                                SMALLINT().notNull())
+                        .testResult(
+                                call("PARSE_JSON", "128").cast(TINYINT()),
+                                "CAST(PARSE_JSON('128') AS TINYINT)",
+                                (byte) -128,
+                                TINYINT().notNull())
+                        .testResult(
+                                call("PARSE_JSON", "2147483648").cast(INT()),
+                                "CAST(PARSE_JSON('2147483648') AS INT)",
+                                -2147483648,
+                                INT().notNull())
+                        .testResult(
+                                call("PARSE_JSON", 
"9223372036854775808").cast(BIGINT()),
+                                "CAST(PARSE_JSON('9223372036854775808') AS 
BIGINT)",
+                                -9223372036854775808L,
+                                BIGINT().notNull())
+                        // An out-of-range floating point value saturates when 
cast to an integer,
+                        // and a fractional value is truncated toward zero.
+                        .testResult(
+                                call("PARSE_JSON", "1e20").cast(INT()),
+                                "CAST(PARSE_JSON('1e20') AS INT)",
+                                2147483647,
+                                INT().notNull())
+                        .testResult(
+                                call("PARSE_JSON", "3.9").cast(INT()),
+                                "CAST(PARSE_JSON('3.9') AS INT)",
+                                3,
+                                INT().notNull())
+                        // A value beyond the FLOAT range becomes infinity.
+                        .testResult(
+                                call("PARSE_JSON", "1e40").cast(FLOAT()),
+                                "CAST(PARSE_JSON('1e40') AS FLOAT)",
+                                Float.POSITIVE_INFINITY,
+                                FLOAT().notNull())
+                        // DECIMAL overflow yields NULL instead of wrapping; 
TRY_CAST surfaces it.
+                        .testResult(
+                                call("PARSE_JSON", 
"123.456").tryCast(DECIMAL(4, 2)),
+                                "TRY_CAST(PARSE_JSON('123.456') AS DECIMAL(4, 
2))",
+                                null,
+                                DECIMAL(4, 2))
+                        .testResult(
+                                call("PARSE_JSON", "42").cast(BIGINT()),
+                                "CAST(PARSE_JSON('42') AS BIGINT)",
+                                42L,
+                                BIGINT().notNull())
+                        .testResult(
+                                call("PARSE_JSON", "true").cast(BOOLEAN()),
+                                "CAST(PARSE_JSON('true') AS BOOLEAN)",
+                                true,
+                                BOOLEAN().notNull())
+                        // TRY_CAST of a value whose kind does not match the 
target returns NULL
+                        .testResult(
+                                call("PARSE_JSON", "\"foo\"").tryCast(INT()),
+                                "TRY_CAST(PARSE_JSON('\"foo\"') AS INT)",
+                                null,
+                                INT())
+                        // A variant that stores a JSON null casts to SQL NULL 
when the target is
+                        // nullable: a nullable variant source, or TRY_CAST 
which forces nullable.
+                        .testResult(
+                                call("TRY_PARSE_JSON", "null").cast(INT()),
+                                "CAST(TRY_PARSE_JSON('null') AS INT)",
+                                null,
+                                INT())
+                        .testResult(
+                                call("PARSE_JSON", "null").tryCast(BOOLEAN()),
+                                "TRY_CAST(PARSE_JSON('null') AS BOOLEAN)",
+                                null,
+                                BOOLEAN())
+                        // A nullable variant with a concrete value still 
casts normally.
+                        .testResult(
+                                call("TRY_PARSE_JSON", "42").cast(INT()),
+                                "CAST(TRY_PARSE_JSON('42') AS INT)",
+                                42,
+                                INT())
+                        // Casting a VARIANT to a string points the user to 
JSON_STRING.
+                        .testTableApiValidationError(
+                                call("PARSE_JSON", "42").cast(STRING()),
+                                "Use the JSON_STRING function to convert a 
VARIANT to its JSON "
+                                        + "string representation."));
+    }
+
     private static List<TestSetSpec> allTypesBasic() {
         return Arrays.asList(
                 CastTestSpecBuilder.testCastTo(BOOLEAN())
@@ -1269,7 +1372,7 @@ public class CastFunctionITCase extends 
BuiltInFunctionTestBase {
     }
 
     private static List<TestSetSpec> decimalCasts() {
-        return Collections.singletonList(
+        return List.of(
                 CastTestSpecBuilder.testCastTo(DECIMAL(8, 4))
                         .fromCase(STRING(), null, null)
                         // rounding
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 4e230667111..adc44c474be 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
@@ -28,13 +28,20 @@ import org.apache.flink.table.types.logical.VarCharType;
 import org.junit.jupiter.api.Test;
 
 import static org.apache.flink.table.api.DataTypes.BIGINT;
+import static org.apache.flink.table.api.DataTypes.BOOLEAN;
+import static org.apache.flink.table.api.DataTypes.BYTES;
+import static org.apache.flink.table.api.DataTypes.DATE;
+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.ROW;
 import static org.apache.flink.table.api.DataTypes.STRING;
 import static org.apache.flink.table.api.DataTypes.STRUCTURED;
 import static org.apache.flink.table.api.DataTypes.TIME;
+import static org.apache.flink.table.api.DataTypes.TIMESTAMP;
+import static org.apache.flink.table.api.DataTypes.TIMESTAMP_LTZ;
 import static org.apache.flink.table.api.DataTypes.TINYINT;
+import static org.apache.flink.table.api.DataTypes.VARIANT;
 import static org.apache.flink.table.types.logical.VarCharType.STRING_TYPE;
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -48,6 +55,7 @@ class CastRuleProviderTest {
                     .build();
     private static final LogicalType INT = INT().getLogicalType();
     private static final LogicalType TINYINT = TINYINT().getLogicalType();
+    private static final LogicalType VARIANT = VARIANT().getLogicalType();
     private static final LogicalType ROW =
             ROW(FIELD("a", INT()), FIELD("b", 
TINYINT().notNull())).getLogicalType();
     private static final LogicalType STRUCTURED =
@@ -108,4 +116,24 @@ class CastRuleProviderTest {
         assertThat(CastRuleProvider.canFail(inputType, ROW(INT(), 
STRING()).getLogicalType()))
                 .isFalse();
     }
+
+    @Test
+    void testResolveVariantToPrimitive() {
+        assertThat(CastRuleProvider.resolve(VARIANT, INT))
+                .isSameAs(VariantToPrimitiveCastRule.INSTANCE);
+        assertThat(CastRuleProvider.resolve(VARIANT, 
BOOLEAN().getLogicalType()))
+                .isSameAs(VariantToPrimitiveCastRule.INSTANCE);
+        assertThat(CastRuleProvider.exists(VARIANT, DECIMAL(10, 
2).getLogicalType())).isTrue();
+        assertThat(CastRuleProvider.exists(VARIANT, 
DATE().getLogicalType())).isTrue();
+        assertThat(CastRuleProvider.exists(VARIANT, 
TIMESTAMP().getLogicalType())).isTrue();
+        assertThat(CastRuleProvider.exists(VARIANT, 
TIMESTAMP_LTZ().getLogicalType())).isTrue();
+        assertThat(CastRuleProvider.exists(VARIANT, 
BYTES().getLogicalType())).isTrue();
+        assertThat(CastRuleProvider.canFail(VARIANT, INT)).isTrue();
+
+        // TIME has no variant counterpart and is not castable
+        assertThat(CastRuleProvider.exists(VARIANT, 
TIME().getLogicalType())).isFalse();
+        // character strings keep going through the display-oriented rule
+        assertThat(CastRuleProvider.resolve(VARIANT, STRING_TYPE))
+                .isSameAs(VariantToStringCastRule.INSTANCE);
+    }
 }
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 2aa6e198a98..8459a804163 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.Variant;
 
 import org.junit.jupiter.api.DynamicTest;
 import org.junit.jupiter.api.TestFactory;
@@ -50,6 +51,7 @@ import org.junit.jupiter.api.parallel.ExecutionMode;
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -95,6 +97,7 @@ import static 
org.apache.flink.table.api.DataTypes.TIMESTAMP_LTZ;
 import static org.apache.flink.table.api.DataTypes.TINYINT;
 import static org.apache.flink.table.api.DataTypes.VARBINARY;
 import static org.apache.flink.table.api.DataTypes.VARCHAR;
+import static org.apache.flink.table.api.DataTypes.VARIANT;
 import static org.apache.flink.table.api.DataTypes.YEAR;
 import static org.apache.flink.table.data.DecimalData.fromBigDecimal;
 import static org.apache.flink.table.data.StringData.fromString;
@@ -1540,7 +1543,100 @@ class CastRulesTest {
                 CastTestSpecBuilder.testCastTo(BYTES())
                         .fromCase(BITMAP(), DEFAULT_BITMAP, 
DEFAULT_BITMAP.toBytes())
                         .fromCase(BITMAP(), Bitmap.empty(), 
Bitmap.empty().toBytes())
-                        .fromCase(BITMAP(), null, null));
+                        .fromCase(BITMAP(), null, null),
+                // From VARIANT to primitive types. Numeric targets are 
lenient: a variant holding
+                // any numeric kind converts to the requested numeric type 
(widening and narrowing).
+                // Non-numeric targets are strict: the stored kind must match, 
otherwise the cast
+                // fails and TRY_CAST returns null.
+                CastTestSpecBuilder.testCastTo(BOOLEAN())
+                        .fromCase(VARIANT(), Variant.newBuilder().of(true), 
true)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(false), 
false)
+                        .fail(VARIANT(), Variant.newBuilder().of(1), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(TINYINT())
+                        .fromCase(VARIANT(), Variant.newBuilder().of((byte) 
42), (byte) 42)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(42), 
(byte) 42)
+                        .fail(VARIANT(), Variant.newBuilder().of("x"), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(SMALLINT())
+                        .fromCase(VARIANT(), Variant.newBuilder().of((short) 
42), (short) 42)
+                        .fromCase(VARIANT(), Variant.newBuilder().of((byte) 
42), (short) 42)
+                        .fail(
+                                VARIANT(),
+                                Variant.newBuilder().of(true),
+                                TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(INT())
+                        // widening: a JSON integer is stored in the smallest 
type but still casts
+                        // up
+                        .fromCase(VARIANT(), Variant.newBuilder().of((byte) 
42), 42)
+                        .fromCase(VARIANT(), Variant.newBuilder().of((short) 
42), 42)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(42), 42)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(42L), 42)
+                        // narrowing from a floating point or decimal value 
truncates
+                        .fromCase(VARIANT(), Variant.newBuilder().of(3.9d), 3)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(new 
BigDecimal("7.2")), 7)
+                        // a non-numeric variant cannot be cast to a number
+                        .fail(
+                                VARIANT(),
+                                Variant.newBuilder().of("foo"),
+                                TableRuntimeException.class)
+                        .fail(
+                                VARIANT(),
+                                Variant.newBuilder().of(true),
+                                TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(BIGINT())
+                        .fromCase(VARIANT(), Variant.newBuilder().of(42L), 42L)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(42), 42L)
+                        .fail(VARIANT(), Variant.newBuilder().of("x"), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(FLOAT())
+                        .fromCase(VARIANT(), Variant.newBuilder().of(1.5f), 
1.5f)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(1.5d), 
1.5f)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(3), 3.0f)
+                        .fail(VARIANT(), Variant.newBuilder().of("x"), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(DOUBLE())
+                        .fromCase(VARIANT(), Variant.newBuilder().of(1.5d), 
1.5d)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(1.5f), 
1.5d)
+                        .fromCase(VARIANT(), Variant.newBuilder().of(3), 3.0d)
+                        .fail(VARIANT(), Variant.newBuilder().of("x"), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(DECIMAL(5, 2))
+                        .fromCase(VARIANT(), null, null)
+                        .fromCase(
+                                VARIANT(),
+                                Variant.newBuilder().of(new 
BigDecimal("123.45")),
+                                DecimalData.fromBigDecimal(new 
BigDecimal("123.45"), 5, 2))
+                        .fromCase(
+                                VARIANT(),
+                                Variant.newBuilder().of(42),
+                                DecimalData.fromBigDecimal(new 
BigDecimal("42"), 5, 2))
+                        .fail(VARIANT(), Variant.newBuilder().of("x"), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(BYTES())
+                        .fromCase(VARIANT(), null, null)
+                        .fromCase(
+                                VARIANT(),
+                                Variant.newBuilder().of(new byte[] {1, 2, 3}),
+                                new byte[] {1, 2, 3})
+                        .fail(
+                                VARIANT(),
+                                Variant.newBuilder().of("foo"),
+                                TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(DATE())
+                        .fromCase(
+                                VARIANT(),
+                                Variant.newBuilder().of(LocalDate.of(2020, 1, 
1)),
+                                (int) LocalDate.of(2020, 1, 1).toEpochDay())
+                        .fail(VARIANT(), Variant.newBuilder().of(1), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(TIMESTAMP())
+                        .fromCase(VARIANT(), null, null)
+                        .fromCase(
+                                VARIANT(),
+                                Variant.newBuilder().of(LocalDateTime.of(2020, 
1, 1, 12, 0, 0)),
+                                TimestampData.fromLocalDateTime(
+                                        LocalDateTime.of(2020, 1, 1, 12, 0, 
0)))
+                        .fail(VARIANT(), Variant.newBuilder().of(1), 
TableRuntimeException.class),
+                CastTestSpecBuilder.testCastTo(TIMESTAMP_LTZ())
+                        .fromCase(
+                                VARIANT(),
+                                
Variant.newBuilder().of(Instant.ofEpochSecond(1_600_000_000L)),
+                                
TimestampData.fromInstant(Instant.ofEpochSecond(1_600_000_000L)))
+                        .fail(VARIANT(), Variant.newBuilder().of(1), 
TableRuntimeException.class));
     }
 
     @TestFactory

Reply via email to