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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new e2c2b4e77f [spark][core] Preserve SQL semantics for CAST predicate 
pushdown (#9980)
e2c2b4e77f is described below

commit e2c2b4e77fa5fb22f21c8f296e26c698a563a1b0
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Sun Sep 20 13:45:46 2026 +0800

    [spark][core] Preserve SQL semantics for CAST predicate pushdown (#9980)
---
 .../org/apache/paimon/predicate/CastTransform.java |   3 +-
 .../apache/paimon/predicate/CastTransformTest.java |  90 ++++++
 .../spark/util/SparkExpressionConverter.scala      |  40 ++-
 .../spark/sql/CastPredicatePushDownTest.scala      | 315 +++++++++++++++++++++
 .../paimon/spark/sql/PaimonPushDownTestBase.scala  |   3 +-
 5 files changed, 444 insertions(+), 7 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/CastTransform.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/CastTransform.java
index 5e811f4961..8487ff4e65 100644
--- a/paimon-common/src/main/java/org/apache/paimon/predicate/CastTransform.java
+++ b/paimon-common/src/main/java/org/apache/paimon/predicate/CastTransform.java
@@ -112,7 +112,8 @@ public class CastTransform implements Transform {
 
     @Override
     public Object transform(InternalRow row) {
-        return cast.cast(get(row, fieldRef.index(), fieldRef.type()));
+        Object value = get(row, fieldRef.index(), fieldRef.type());
+        return value == null ? null : cast.cast(value);
     }
 
     @Override
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/predicate/CastTransformTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/predicate/CastTransformTest.java
new file mode 100644
index 0000000000..816c185398
--- /dev/null
+++ 
b/paimon-common/src/test/java/org/apache/paimon/predicate/CastTransformTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.paimon.predicate;
+
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.apache.paimon.utils.JsonSerdeUtil;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.Collections;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CastTransformTest {
+
+    private static Stream<Arguments> nullableCasts() {
+        return Stream.of(
+                Arguments.of(DataTypes.INT(), DataTypes.BIGINT()),
+                Arguments.of(DataTypes.INT(), DataTypes.STRING()),
+                Arguments.of(DataTypes.BOOLEAN(), DataTypes.STRING()),
+                Arguments.of(DataTypes.STRING(), DataTypes.INT()),
+                Arguments.of(DataTypes.STRING(), DataTypes.DATE()),
+                Arguments.of(DataTypes.TIMESTAMP(6), DataTypes.STRING()),
+                Arguments.of(DataTypes.DECIMAL(8, 2), DataTypes.DECIMAL(10, 
3)));
+    }
+
+    @ParameterizedTest
+    @MethodSource("nullableCasts")
+    void testNullPropagation(DataType source, DataType target) throws 
Exception {
+        CastTransform transform = new CastTransform(new FieldRef(0, "f", 
source), target);
+        GenericRow row = GenericRow.of((Object) null);
+        assertThat(transform.transform(row)).isNull();
+        assertThat(
+                        CastTransform.tryCreate(new FieldRef(0, "f", source), 
target)
+                                .get()
+                                .transform(row))
+                .isNull();
+        Transform javaCopy =
+                InstantiationUtil.deserializeObject(
+                        InstantiationUtil.serializeObject(transform), 
getClass().getClassLoader());
+        assertThat(javaCopy.transform(row)).isNull();
+        Transform jsonCopy =
+                JsonSerdeUtil.fromJson(JsonSerdeUtil.toJson(transform), 
Transform.class);
+        assertThat(jsonCopy.transform(row)).isNull();
+        Transform projected =
+                transform.copyWithNewInputs(
+                        Collections.singletonList(new FieldRef(1, "f", 
source)));
+        assertThat(projected.transform(GenericRow.of(1, null))).isNull();
+    }
+
+    @Test
+    void testNonNullCastsUnchanged() {
+        assertThat(
+                        new CastTransform(new FieldRef(0, "f", 
DataTypes.INT()), DataTypes.BIGINT())
+                                .transform(GenericRow.of(Integer.MAX_VALUE)))
+                .isEqualTo((long) Integer.MAX_VALUE);
+        assertThat(
+                        new CastTransform(new FieldRef(0, "f", 
DataTypes.INT()), DataTypes.STRING())
+                                .transform(GenericRow.of(-123)))
+                .isEqualTo(BinaryString.fromString("-123"));
+        assertThat(
+                        new CastTransform(new FieldRef(0, "f", 
DataTypes.STRING()), DataTypes.INT())
+                                
.transform(GenericRow.of(BinaryString.fromString("123"))))
+                .isEqualTo(123);
+    }
+}
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SparkExpressionConverter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SparkExpressionConverter.scala
index 0e54b10075..95cd0440bc 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SparkExpressionConverter.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SparkExpressionConverter.scala
@@ -22,12 +22,12 @@ import org.apache.paimon.data.{BinaryString, Decimal, 
Timestamp}
 import org.apache.paimon.predicate._
 import org.apache.paimon.spark.{PaimonImplicits, SparkTypeUtils}
 import 
org.apache.paimon.spark.util.shim.TypeUtils.treatPaimonTimestampTypeAsSparkTimestampType
-import org.apache.paimon.types.{DecimalType, RowType}
+import org.apache.paimon.types.{DataTypeChecks, DataTypeFamily, DecimalType, 
RowType}
 import org.apache.paimon.types.DataTypeRoot._
 
 import org.apache.spark.sql.catalyst.util.{ArrayData, DateTimeUtils}
 import org.apache.spark.sql.connector.expressions.{Cast, Expression, Extract, 
GeneralScalarExpression, Literal, NamedReference}
-import org.apache.spark.sql.types.{ArrayType => SparkArrayType, DataType => 
SparkDataType}
+import org.apache.spark.sql.types.{ArrayType => SparkArrayType, BooleanType, 
ByteType, DataType => SparkDataType, DoubleType, FloatType, IntegerType, 
LongType, ShortType, StringType}
 
 import scala.collection.JavaConverters._
 
@@ -163,9 +163,7 @@ object SparkExpressionConverter {
       case c: Cast =>
         c.expression() match {
           case n: NamedReference =>
-            CastTransform.tryCreate(
-              toPaimonFieldRef(n, rowType),
-              SparkTypeUtils.toPaimonType(c.dataType()))
+            toPaimonCast(toPaimonFieldRef(n, rowType), c.dataType())
           case _ => None
         }
       // The connector `Extract` expression was added in Spark 3.4 and does 
not exist on
@@ -208,6 +206,38 @@ object SparkExpressionConverter {
     }
   }
 
+  private def toPaimonCast(field: FieldRef, target: SparkDataType): 
Option[Transform] = {
+    val source = SparkTypeUtils.fromPaimonType(field.`type`())
+    val losesTimestampPrecision = field.`type`().is(DataTypeFamily.TIMESTAMP) 
&&
+      DataTypeChecks.getPrecision(field.`type`()) > 6
+    if (target == FloatType || target == DoubleType || 
losesTimestampPrecision) {
+      // Spark equates signed zeros while the storage predicates distinguish 
them. Also,
+      // Spark exposes timestamps in microseconds, so a high-precision field 
is not an identity.
+      None
+    } else if (source == target) {
+      Some(new FieldTransform(field))
+    } else if 
(!field.`type`().equalsIgnoreNullable(SparkTypeUtils.toPaimonType(source))) {
+      // Some storage types have a different Spark representation (for example 
TIME -> INT).
+      // Their storage casts cannot be inferred from the exposed Spark source 
type.
+      None
+    } else {
+      // Storage casts do not carry Spark's ANSI mode or session time zone. 
Only accept
+      // conversions whose values and failure behavior agree independently of 
those settings.
+      // In particular, numeric narrowing, string parsing, decimal casts and 
temporal casts
+      // must remain in Spark rather than removing rows or suppressing an ANSI 
error.
+      val safe = (source, target) match {
+        case (ByteType, ShortType | IntegerType | LongType | StringType) =>
+          true
+        case (ShortType, IntegerType | LongType | StringType) => true
+        case (IntegerType, LongType | StringType) => true
+        case (LongType, StringType) => true
+        case (BooleanType, StringType) => true
+        case _ => false
+      }
+      if (safe) CastTransform.tryCreate(field, 
SparkTypeUtils.toPaimonType(target)) else None
+    }
+  }
+
   /** Convert Spark [[Literal]] to Paimon literal. */
   def toPaimonLiteral(literal: Literal[_]): Object = {
     if (literal == null) {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CastPredicatePushDownTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CastPredicatePushDownTest.scala
new file mode 100644
index 0000000000..d2a41bf50c
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CastPredicatePushDownTest.scala
@@ -0,0 +1,315 @@
+/*
+ * 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.paimon.spark.sql
+
+import org.apache.paimon.data.{BinaryString, GenericRow}
+import org.apache.paimon.predicate.FieldTransform
+import org.apache.paimon.spark.{PaimonSparkTestBase, SparkTypeUtils}
+import org.apache.paimon.spark.util.SparkExpressionConverter
+import org.apache.paimon.types.{DataTypes => PaimonTypes, RowType}
+
+import org.apache.spark.sql.catalyst.expressions.{Cast => CatalystCast, 
Literal => CatalystLiteral}
+import org.apache.spark.sql.catalyst.plans.logical.Filter
+import org.apache.spark.sql.connector.expressions.{Cast, Expressions}
+import org.apache.spark.sql.types._
+
+import java.util.{Objects, TimeZone}
+
+/** SQL casts must remain in Spark unless the storage transform has equivalent 
semantics. */
+class CastPredicatePushDownTest extends PaimonSparkTestBase {
+
+  private val integralTypes = Seq(ByteType, ShortType, IntegerType, LongType)
+
+  test("safe CAST conversion agrees with Catalyst for boundary values and 
nulls") {
+    val values: Seq[(DataType, Seq[Any])] = Seq(
+      ByteType -> Seq(Byte.MinValue, 0.toByte, Byte.MaxValue, null),
+      ShortType -> Seq(Short.MinValue, 0.toShort, Short.MaxValue, null),
+      IntegerType -> Seq(Int.MinValue, -1, 0, Int.MaxValue, null),
+      LongType -> Seq(Long.MinValue, -1L, 0L, 16777217L, Long.MaxValue, null),
+      BooleanType -> Seq(true, false, null)
+    )
+    for ((source, inputs) <- values; ansi <- Seq(false, true)) {
+      val targets = source match {
+        case BooleanType => Seq(BooleanType, StringType)
+        case _ =>
+          integralTypes
+            .drop(integralTypes.indexOf(source)) ++ Seq(StringType)
+      }
+      withSparkSQLConf("spark.sql.ansi.enabled" -> ansi.toString) {
+        val rowType = RowType.of(Array(SparkTypeUtils.toPaimonType(source)), 
Array("p"))
+        for (target <- targets) {
+          val transform = SparkExpressionConverter.toPaimonTransform(
+            new Cast(Expressions.column("p"), target),
+            rowType)
+          assert(transform.nonEmpty, s"$source -> $target")
+          for (value <- inputs) {
+            val result =
+              CatalystCast(CatalystLiteral.create(value, source), target, 
Some("UTC")).eval()
+            val expected =
+              if (result == null) null
+              else if (target == StringType) 
BinaryString.fromString(result.toString)
+              else result.asInstanceOf[AnyRef]
+            val actual = 
transform.get.transform(GenericRow.of(value.asInstanceOf[AnyRef]))
+            assert(Objects.equals(actual, expected), s"$source -> $target: 
$value, ANSI=$ansi")
+          }
+        }
+      }
+    }
+  }
+
+  test("unsafe CAST conversions are declined instead of using storage casts") {
+    val unsafe: Seq[(DataType, DataType)] = Seq(
+      IntegerType -> ByteType,
+      LongType -> IntegerType,
+      DoubleType -> FloatType,
+      FloatType -> IntegerType,
+      DoubleType -> LongType,
+      StringType -> IntegerType,
+      StringType -> LongType,
+      StringType -> DoubleType,
+      StringType -> BooleanType,
+      StringType -> DateType,
+      StringType -> TimestampType,
+      TimestampType -> StringType,
+      TimestampType -> DateType,
+      DateType -> TimestampType,
+      DateType -> StringType,
+      IntegerType -> TimestampType,
+      FloatType -> StringType,
+      DoubleType -> StringType,
+      DecimalType(10, 2) -> DecimalType(5, 1),
+      DecimalType(10, 2) -> LongType,
+      LongType -> DecimalType(5, 0)
+    )
+    for ((source, target) <- unsafe) {
+      val rowType = RowType.of(Array(SparkTypeUtils.toPaimonType(source)), 
Array("p"))
+      assert(
+        SparkExpressionConverter
+          .toPaimonTransform(new Cast(Expressions.column("p"), target), 
rowType)
+          .isEmpty,
+        s"$source -> $target")
+    }
+  }
+
+  test("sub-microsecond timestamp CAST must not be treated as a field 
identity") {
+    if (gteqSpark3_4) {
+      for (precision <- 7 to 9; legacy <- Seq(false, true)) {
+        withSparkSQLConf("spark.paimon.legacy-timestamp-mapping.enabled" -> 
legacy.toString) {
+          for (
+            source <- Seq(
+              PaimonTypes.TIMESTAMP(precision),
+              PaimonTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(precision))
+          ) {
+            val rowType = 
RowType.of(Array[org.apache.paimon.types.DataType](source), Array("p"))
+            val exposed = SparkTypeUtils.fromPaimonType(source)
+            assert(
+              SparkExpressionConverter
+                .toPaimonTransform(new Cast(Expressions.column("p"), exposed), 
rowType)
+                .isEmpty)
+          }
+        }
+      }
+    }
+  }
+
+  test("floating-result CAST predicates stay in Spark because signed zero 
comparisons differ") {
+    for (
+      source <- integralTypes ++ Seq(FloatType, DoubleType); target <- 
Seq(FloatType, DoubleType)
+    ) {
+      val rowType = RowType.of(Array(SparkTypeUtils.toPaimonType(source)), 
Array("p"))
+      assert(
+        SparkExpressionConverter
+          .toPaimonTransform(new Cast(Expressions.column("p"), target), 
rowType)
+          .isEmpty,
+        s"$source -> $target")
+    }
+    val sparkEquality = org.apache.spark.sql.catalyst.expressions.EqualTo(
+      CatalystCast(CatalystLiteral.create(-0.0f, FloatType), DoubleType, 
Some("UTC")),
+      CatalystLiteral.create(0.0d, DoubleType))
+    assert(sparkEquality.eval() == true)
+  }
+
+  test("storage TIME exposed as Spark INT must not use clock-format casts") {
+    val rowType =
+      RowType.of(Array[org.apache.paimon.types.DataType](PaimonTypes.TIME(3)), 
Array("p"))
+    assert(
+      SparkExpressionConverter
+        .toPaimonTransform(new Cast(Expressions.column("p"), IntegerType), 
rowType)
+        .get
+        .isInstanceOf[FieldTransform])
+    assert(
+      SparkExpressionConverter
+        .toPaimonTransform(new Cast(Expressions.column("p"), StringType), 
rowType)
+        .isEmpty)
+  }
+
+  test("timestamp identity is based on the Spark exposed type") {
+    if (gteqSpark3_4) {
+      for (legacy <- Seq(false, true)) {
+        withSparkSQLConf("spark.paimon.legacy-timestamp-mapping.enabled" -> 
legacy.toString) {
+          val rowType = RowType.of(
+            Array[org.apache.paimon.types.DataType](PaimonTypes.TIMESTAMP(6)),
+            Array("p"))
+          val exposed = SparkTypeUtils.fromPaimonType(rowType.getTypeAt(0))
+          assert(
+            SparkExpressionConverter
+              .toPaimonTransform(new Cast(Expressions.column("p"), exposed), 
rowType)
+              .get
+              .isInstanceOf[FieldTransform])
+          val different = if (legacy) TimestampNTZType else TimestampType
+          assert(
+            SparkExpressionConverter
+              .toPaimonTransform(new Cast(Expressions.column("p"), different), 
rowType)
+              .isEmpty)
+        }
+      }
+    }
+  }
+
+  for (format <- Seq("parquet", "orc"); partitioned <- Seq(false, true); ansi 
<- Seq(false, true)) {
+    val context = s"$format, partitioned=$partitioned, ANSI=$ansi"
+
+    test(s"CAST preserves NULL and safe results: $context") {
+      withInput("INT", "(1, NULL), (2, -1), (3, 0), (4, 128)", format, 
partitioned, ansi) {
+        for (
+          predicate <- Seq(
+            "CAST(p AS BIGINT) IS NULL",
+            "CAST(p AS STRING) IS NULL",
+            "CAST(p AS STRING) = '128'",
+            "NOT (CAST(p AS STRING) <=> '128')",
+            "CAST(p AS BIGINT) > 0")
+        ) {
+          checkAgainstSpark(predicate)
+        }
+      }
+    }
+
+    test(s"CAST overflow and decimal narrowing follow Spark: $context") {
+      withInput("INT", "(1, 128)", format, partitioned, ansi) {
+        if (ansi) {
+          assertSameError("CAST(p AS TINYINT) = -128", "CAST_OVERFLOW")
+        } else {
+          checkAgainstSpark("CAST(p AS TINYINT) = -128")
+        }
+      }
+      withInput("DECIMAL(6,2)", "(1, 9999.99), (2, NULL)", format, 
partitioned, ansi) {
+        if (ansi) {
+          val reference = intercept[Exception](
+            sql("SELECT id FROM cast_reference WHERE CAST(p AS DECIMAL(3,1)) 
IS NULL").collect())
+          val actual = intercept[Exception](
+            sql("SELECT id FROM cast_subject WHERE CAST(p AS DECIMAL(3,1)) IS 
NULL").collect())
+          assert(allMessages(reference).contains("NUMERIC_VALUE_OUT_OF_RANGE"))
+          assert(allMessages(actual).contains("NUMERIC_VALUE_OUT_OF_RANGE"))
+        } else {
+          checkAgainstSpark("CAST(p AS DECIMAL(3,1)) IS NULL")
+        }
+      }
+    }
+
+    test(s"parsed string CAST remains in Spark: $context") {
+      withInput("STRING", "(1, '1970'), (2, '2024-01-02'), (3, NULL)", format, 
partitioned, ansi) {
+        checkAgainstSpark("CAST(p AS DATE) = DATE '1970-01-01'")
+        checkAgainstSpark("CAST(p AS DATE) IS NULL")
+        assert(
+          sql(
+            "SELECT id FROM cast_subject WHERE CAST(p AS DATE) = DATE 
'1970-01-01'").queryExecution.optimizedPlan
+            .exists(_.isInstanceOf[Filter]))
+      }
+      withInput("STRING", "(1, 'bad'), (2, '123'), (3, NULL)", format, 
partitioned, ansi) {
+        if (ansi) assertSameError("CAST(p AS INT) IS NULL", 
"CAST_INVALID_INPUT")
+        else checkAgainstSpark("CAST(p AS INT) IS NULL")
+      }
+    }
+  }
+
+  test("timestamp CAST uses Spark formatting and session time zone") {
+    val previous = TimeZone.getDefault
+    try {
+      TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
+      for (zone <- Seq("UTC", "Asia/Shanghai"); partitioned <- Seq(false, 
true)) {
+        withSparkSQLConf("spark.sql.session.timeZone" -> zone) {
+          withInput(
+            "TIMESTAMP",
+            "(1, TIMESTAMP '2024-01-01 00:30:00'), (2, NULL)",
+            "parquet",
+            partitioned,
+            true) {
+            checkAgainstSpark("CAST(p AS STRING) = '2024-01-01 00:30:00'")
+            checkAgainstSpark("CAST(p AS STRING) IS NULL")
+          }
+        }
+      }
+    } finally TimeZone.setDefault(previous)
+  }
+
+  test("safe partition CAST retains pruning and unsafe CAST retains residual") 
{
+    withInput("INT", "(1, 1), (2, 2), (3, 3), (4, NULL)", "parquet", true, 
true) {
+      val all = getPaimonScan("SELECT * FROM cast_subject").inputSplits.length
+      val query = "SELECT * FROM cast_subject WHERE CAST(p AS STRING) = '2'"
+      val scan = getPaimonScan(query)
+      
assert(!sql(query).queryExecution.optimizedPlan.exists(_.isInstanceOf[Filter]))
+      assert(scan.inputSplits.nonEmpty && scan.inputSplits.length < all)
+      checkAgainstSpark("CAST(p AS STRING) = '2'")
+    }
+    withInput("STRING", "(1, '1970'), (2, '2024-01-02')", "parquet", true, 
true) {
+      val query = "SELECT * FROM cast_subject WHERE CAST(p AS DATE) = DATE 
'1970-01-01'"
+      
assert(sql(query).queryExecution.optimizedPlan.exists(_.isInstanceOf[Filter]))
+      checkAgainstSpark("CAST(p AS DATE) = DATE '1970-01-01'")
+    }
+  }
+
+  private def withInput(
+      source: String,
+      values: String,
+      format: String,
+      partitioned: Boolean,
+      ansi: Boolean)(body: => Unit): Unit = {
+    withSparkSQLConf("spark.sql.ansi.enabled" -> ansi.toString) {
+      withTable("cast_subject") {
+        val input = sql(s"SELECT id, CAST(p AS $source) AS p FROM VALUES 
$values AS input(id,p)")
+        input.createOrReplaceTempView("cast_reference")
+        try {
+          val partition = if (partitioned) "PARTITIONED BY (p)" else ""
+          sql(
+            s"CREATE TABLE cast_subject (id INT, p $source) USING paimon 
$partition TBLPROPERTIES ('bucket'='-1','file.format'='$format')")
+          sql("INSERT INTO cast_subject SELECT * FROM cast_reference")
+          body
+        } finally spark.catalog.dropTempView("cast_reference")
+      }
+    }
+  }
+
+  private def checkAgainstSpark(predicate: String): Unit = {
+    val expected = sql(s"SELECT id, p FROM cast_reference WHERE 
$predicate").collect().toSeq
+    checkAnswer(sql(s"SELECT id, p FROM cast_subject WHERE $predicate"), 
expected)
+  }
+
+  private def allMessages(error: Throwable): String = {
+    Iterator.iterate(error)(_.getCause).takeWhile(_ != 
null).map(_.getMessage).mkString("\n")
+  }
+
+  private def assertSameError(predicate: String, errorClass: String): Unit = {
+    val expected =
+      intercept[Exception](sql(s"SELECT id FROM cast_reference WHERE 
$predicate").collect())
+    val actual =
+      intercept[Exception](sql(s"SELECT id FROM cast_subject WHERE 
$predicate").collect())
+    assert(allMessages(expected).contains(errorClass))
+    assert(allMessages(actual).contains(errorClass))
+  }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonPushDownTestBase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonPushDownTestBase.scala
index 38ec748709..71fce5b4da 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonPushDownTestBase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PaimonPushDownTestBase.scala
@@ -314,7 +314,8 @@ abstract class PaimonPushDownTestBase extends 
PaimonSparkTestBase with AdaptiveS
                 |""".stripMargin)
 
           val q = "SELECT * FROM t WHERE dt = 1"
-          assert(!checkFilterExists(q))
+          // String-to-number storage casts do not implement Spark's ANSI 
semantics.
+          assert(checkFilterExists(q))
           checkAnswer(sql(q), Seq(Row(1, 100, "1")))
         }
       }

Reply via email to