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

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


The following commit(s) were added to refs/heads/master by this push:
     new b71b10bf477f fix(spark): reject unsupported procedure filter functions 
(#19850)
b71b10bf477f is described below

commit b71b10bf477f45ac161fc4ef4fd8da71e6c3ec8a
Author: Ryu <[email protected]>
AuthorDate: Sun Sep 6 18:32:58 2026 +0200

    fix(spark): reject unsupported procedure filter functions (#19850)
    
    * fix(spark): reject unsupported procedure filter functions
    
    * fix(spark): validate resolved filter expressions
    
    * fix(spark): align filter validation coercion
---
 .../procedures/HoodieProcedureFilterUtils.scala    | 78 ++++++++++++++--------
 .../procedure/TestHoodieProcedureFilterUtils.scala | 42 +++++++-----
 .../hudi/procedure/TestShowCleansProcedures.scala  |  4 ++
 3 files changed, 81 insertions(+), 43 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala
 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala
index b0938733cbac..d0783fb872a5 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala
@@ -18,12 +18,14 @@
 package org.apache.spark.sql.hudi.command.procedures
 
 import org.apache.spark.sql.{Row, SparkSession}
-import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute
-import org.apache.spark.sql.catalyst.expressions.{Expression, 
GenericInternalRow}
+import org.apache.spark.sql.catalyst.analysis.{UnresolvedAttribute, 
UnresolvedFunction}
+import org.apache.spark.sql.catalyst.expressions.{Expression, 
GenericInternalRow, Unevaluable}
 import org.apache.spark.sql.catalyst.util.DateTimeUtils
 import org.apache.spark.sql.types.{DataType, StructType}
 import org.apache.spark.unsafe.types.UTF8String
 
+import java.util.Locale
+
 import scala.collection.JavaConverters._
 import scala.util.{Failure, Success, Try}
 
@@ -70,13 +72,9 @@ object HoodieProcedureFilterUtils {
     }
   }
 
-  private def evaluateExpressionOnRow(expression: Expression, row: Row, 
schema: StructType): Boolean = {
-
-    val internalRow = convertRowToInternalRow(row, schema)
-
-    Try {
-      // First pass: bind attributes
-      val attributeBound = expression.transform {
+  private def bindAndResolveExpression(expression: Expression, schema: 
StructType): Expression = {
+    // First pass: bind attributes
+    val attributeBound = expression.transform {
         case attr: org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute 
=>
           try {
             val fieldIndex = schema.fieldIndex(attr.name)
@@ -87,10 +85,10 @@ object HoodieProcedureFilterUtils {
           }
       }
 
-      // Second pass: resolve functions
-      val functionResolved = attributeBound.transform {
+    // Second pass: resolve functions
+    val functionResolved = attributeBound.transform {
         case unresolvedFunc: 
org.apache.spark.sql.catalyst.analysis.UnresolvedFunction =>
-          unresolvedFunc.nameParts.head.toLowerCase match {
+          unresolvedFunc.nameParts.head.toLowerCase(Locale.ROOT) match {
             case "upper" =>
               if (unresolvedFunc.arguments.length == 1) {
                 
org.apache.spark.sql.catalyst.expressions.Upper(unresolvedFunc.arguments.head)
@@ -352,21 +350,29 @@ object HoodieProcedureFilterUtils {
               }
             case _ => unresolvedFunc
           }
-      }
+    }
 
-      // Third pass: handle type coercion for numeric comparisons
-      val boundExpr = functionResolved.transformUp {
-        case eq: org.apache.spark.sql.catalyst.expressions.EqualTo =>
-          applyTypeCoercion(eq.left, eq.right, 
org.apache.spark.sql.catalyst.expressions.EqualTo.apply, eq)
-        case gt: org.apache.spark.sql.catalyst.expressions.GreaterThan =>
-          applyTypeCoercion(gt.left, gt.right, 
org.apache.spark.sql.catalyst.expressions.GreaterThan.apply, gt)
-        case gte: org.apache.spark.sql.catalyst.expressions.GreaterThanOrEqual 
=>
-          applyTypeCoercion(gte.left, gte.right, 
org.apache.spark.sql.catalyst.expressions.GreaterThanOrEqual.apply, gte)
-        case lt: org.apache.spark.sql.catalyst.expressions.LessThan =>
-          applyTypeCoercion(lt.left, lt.right, 
org.apache.spark.sql.catalyst.expressions.LessThan.apply, lt)
-        case lte: org.apache.spark.sql.catalyst.expressions.LessThanOrEqual =>
-          applyTypeCoercion(lte.left, lte.right, 
org.apache.spark.sql.catalyst.expressions.LessThanOrEqual.apply, lte)
-      }
+    // Third pass: handle type coercion for numeric comparisons
+    functionResolved.transformUp {
+      case eq: org.apache.spark.sql.catalyst.expressions.EqualTo =>
+        applyTypeCoercion(eq.left, eq.right, 
org.apache.spark.sql.catalyst.expressions.EqualTo.apply, eq)
+      case gt: org.apache.spark.sql.catalyst.expressions.GreaterThan =>
+        applyTypeCoercion(gt.left, gt.right, 
org.apache.spark.sql.catalyst.expressions.GreaterThan.apply, gt)
+      case gte: org.apache.spark.sql.catalyst.expressions.GreaterThanOrEqual =>
+        applyTypeCoercion(gte.left, gte.right, 
org.apache.spark.sql.catalyst.expressions.GreaterThanOrEqual.apply, gte)
+      case lt: org.apache.spark.sql.catalyst.expressions.LessThan =>
+        applyTypeCoercion(lt.left, lt.right, 
org.apache.spark.sql.catalyst.expressions.LessThan.apply, lt)
+      case lte: org.apache.spark.sql.catalyst.expressions.LessThanOrEqual =>
+        applyTypeCoercion(lte.left, lte.right, 
org.apache.spark.sql.catalyst.expressions.LessThanOrEqual.apply, lte)
+    }
+  }
+
+  private def evaluateExpressionOnRow(expression: Expression, row: Row, 
schema: StructType): Boolean = {
+
+    val internalRow = convertRowToInternalRow(row, schema)
+
+    Try {
+      val boundExpr = bindAndResolveExpression(expression, schema)
       val result = boundExpr.eval(internalRow)
 
       result match {
@@ -468,9 +474,22 @@ object HoodieProcedureFilterUtils {
         val columnNames = schema.fieldNames.toSet
         val referencedColumns = extractColumnReferences(parsedExpr)
         val invalidColumns = referencedColumns -- columnNames
+        val resolvedExpr = bindAndResolveExpression(parsedExpr, schema)
+        val unsupportedFunctions = extractFunctionReferences(resolvedExpr)
+        val unsupportedExpressions = resolvedExpr.collect {
+          case expression: Unevaluable
+            if !expression.isInstanceOf[UnresolvedAttribute]
+              && !expression.isInstanceOf[UnresolvedFunction] => 
expression.prettyName
+        }.toSet
 
         if (invalidColumns.nonEmpty) {
           Left(s"Invalid column references: ${invalidColumns.mkString(", ")}. 
Available columns: ${columnNames.mkString(", ")}")
+        } else if (unsupportedFunctions.nonEmpty) {
+          Left(s"Unsupported functions: 
${unsupportedFunctions.toSeq.sorted.mkString(", ")}")
+        } else if (!resolvedExpr.resolved || unsupportedExpressions.nonEmpty) {
+          val names = unsupportedExpressions.toSeq.sorted
+          val detail = if (names.nonEmpty) s": ${names.mkString(", ")}" else ""
+          Left(s"Unsupported filter expression$detail")
         } else {
           Right(())
         }
@@ -481,6 +500,12 @@ object HoodieProcedureFilterUtils {
     }
   }
 
+  private def extractFunctionReferences(expression: Expression): Set[String] = 
expression match {
+    case unresolved: UnresolvedFunction =>
+      Set(unresolved.nameParts.mkString(".")) ++ 
unresolved.children.flatMap(extractFunctionReferences)
+    case _ => expression.children.flatMap(extractFunctionReferences).toSet
+  }
+
   private def extractColumnReferences(expression: Expression): Set[String] = {
     import org.apache.spark.sql.catalyst.expressions._
 
@@ -505,4 +530,3 @@ object HoodieProcedureFilterUtils {
     }
   }
 }
-
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
index 595b49a67929..8478129a6f8c 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
@@ -37,6 +37,9 @@ class TestHoodieProcedureFilterUtils extends 
HoodieSparkProcedureTestBase {
   private def keep(rows: Seq[Row], expr: String, schema: StructType): Seq[Row] 
=
     HoodieProcedureFilterUtils.evaluateFilter(rows, expr, schema, spark)
 
+  private def validate(expr: String, schema: StructType = scalarSchema): 
Either[String, Unit] =
+    HoodieProcedureFilterUtils.validateFilterExpression(expr, schema, spark)
+
   // A rich scalar schema reused across the function tests.
   private val scalarSchema = schemaOf(
     "id" -> IntegerType,
@@ -120,22 +123,12 @@ class TestHoodieProcedureFilterUtils extends 
HoodieSparkProcedureTestBase {
     assertResult(Seq.empty)(keep(scalarRows, "price > 15.0", scalarSchema))
   }
 
-  test("evaluateFilter silently drops rows for functions outside the 
resolution table") {
-    // Known limitation: a function missing from the resolution table falls 
through as an
-    // UnresolvedFunction. validateFilterExpression only checks column 
references, so nothing
-    // rejects it; instead evaluation fails per row and the row is dropped, 
which looks like an
-    // empty result rather than an error. Pinned here so a fix flips these; 
see #19638.
+  test("evaluateFilter silently drops rows for expressions it cannot resolve") 
{
     assertResult(Seq.empty)(keep(scalarRows, "concat(name, 'x') = 'a1x'", 
scalarSchema))
     assertResult(Seq.empty)(keep(scalarRows, "instr(name, 'a') = 1", 
scalarSchema))
-    assertResult(Right(()))(
-      HoodieProcedureFilterUtils.validateFilterExpression("concat(name, 'x') = 
'a1x'", scalarSchema, spark))
-    // if() is parsed as a function call and hits the same gap, while the 
equivalent CASE WHEN is
-    // lowered by the parser without an UnresolvedFunction and evaluates fine.
     assertResult(Seq.empty)(keep(scalarRows, "if(name = 'a1', true, false)", 
scalarSchema))
     assertResult(Seq(scalarRows.head))(
       keep(scalarRows, "case when name = 'a1' then true else false end", 
scalarSchema))
-    // Control: a function that is in the resolution table resolves and 
matches.
-    assertResult(Seq(scalarRows.head))(keep(scalarRows, "upper(name) = 'A1'", 
scalarSchema))
   }
 
   test("evaluateFilter handles AND / OR / NOT / IN / BETWEEN") {
@@ -291,20 +284,37 @@ class TestHoodieProcedureFilterUtils extends 
HoodieSparkProcedureTestBase {
 
   test("validateFilterExpression accepts valid references and rejects unknown 
ones") {
     assertResult(Right(()))(
-      HoodieProcedureFilterUtils.validateFilterExpression("id > 1 AND name = 
'a1'", scalarSchema, spark))
+      validate("id > 1 AND name = 'a1'"))
+    assertResult(Right(()))(
+      validate("ts >= 0 AND ts BETWEEN 0 AND 999999"))
     assertResult(Right(()))(
-      HoodieProcedureFilterUtils.validateFilterExpression(null, scalarSchema, 
spark))
+      validate(null))
     assertResult(Right(()))(
-      HoodieProcedureFilterUtils.validateFilterExpression("   ", scalarSchema, 
spark))
+      validate("   "))
 
-    val invalidCol = 
HoodieProcedureFilterUtils.validateFilterExpression("missing_col > 1", 
scalarSchema, spark)
+    val invalidCol = validate("missing_col > 1")
     assert(invalidCol.isLeft)
     val invalidColMsg = invalidCol.fold(identity, _ => "")
     assert(invalidColMsg.contains("Invalid column references"))
     assert(invalidColMsg.contains("missing_col"))
 
-    val parseError = HoodieProcedureFilterUtils.validateFilterExpression("id 
>< 1", scalarSchema, spark)
+    val parseError = validate("id >< 1")
     assert(parseError.isLeft)
     assert(parseError.fold(identity, _ => "").contains("Invalid filter 
expression"))
   }
+
+  test("validateFilterExpression rejects expressions the evaluator cannot 
resolve") {
+    val unknown = validate("concat(name, 'x') = 'a1x' OR instr(name, 'a') = 1")
+    assert(unknown.left.exists(_.contains("Unsupported functions: concat, 
instr")))
+
+    assert(validate("if(name = 'a1', true, false)").isLeft)
+    assert(validate("substring(name, 2)").isLeft)
+    assert(validate("id = 1 OR concat(name, 'x') = 'a1x'").isLeft)
+    assert(validate("hour(t) = 12").isLeft)
+    assert(validate("date_format(t, 'yyyy') = '2024'").isLeft)
+    assert(validate("any_value(id) = 1").isLeft)
+    assert(validate("id = (select 1)").isLeft)
+
+    assertResult(Right(()))(validate("upper(name) = 'A1'"))
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
index 9fac4df2f072..c5d8792712c5 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
@@ -636,6 +636,10 @@ class TestShowCleansProcedures extends 
HoodieSparkProcedureTestBase {
         checkExceptionContain(
           s"""call show_clean_plans(table => '$tableName', filter => 
"nonexistent_col > 1")""")(
           "Invalid column references: nonexistent_col")
+
+        checkExceptionContain(
+          s"""call show_clean_plans(table => '$tableName', filter => 
"concat(action, 'x') = 'cleanx'")""")(
+          "Unsupported functions: concat")
       }
     }
   }

Reply via email to