voonhous commented on code in PR #19850:
URL: https://github.com/apache/hudi/pull/19850#discussion_r3943742597


##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -468,9 +475,13 @@ object HoodieProcedureFilterUtils {
         val columnNames = schema.fieldNames.toSet
         val referencedColumns = extractColumnReferences(parsedExpr)
         val invalidColumns = referencedColumns -- columnNames
+        val unsupportedFunctions = extractFunctionReferences(parsedExpr) -- 
SupportedFunctionNames
 
         if (invalidColumns.nonEmpty) {
           Left(s"Invalid column references: ${invalidColumns.mkString(", ")}. 
Available columns: ${columnNames.mkString(", ")}")
+        } else if (unsupportedFunctions.nonEmpty) {

Review Comment:
   **minor:** Not blocking, but this is a behavior change worth pinning. 
`Or.eval` skips its right child when the left is true, so `id = 1 OR 
concat(name, 'x') = 'a1x'` returns row 1 on master; after this change 
`BaseProcedure.validateFilter` throws for the same string. Rejecting it looks 
like the better contract, the test is just that validation is now 
presence-based rather than reachability-based.
   
   Could we add an assertion pinning that, so the change is deliberate rather 
than incidental?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -468,9 +475,13 @@ object HoodieProcedureFilterUtils {
         val columnNames = schema.fieldNames.toSet
         val referencedColumns = extractColumnReferences(parsedExpr)
         val invalidColumns = referencedColumns -- columnNames
+        val unsupportedFunctions = extractFunctionReferences(parsedExpr) -- 
SupportedFunctionNames

Review Comment:
   **major:** Matching on names alone under-approximates what actually 
evaluates, so #19638 stays half-open. Both shapes below pass validation and 
still silently drop every row.
   
   Wrong arity of a listed name falls through `case _ => unresolvedFunc` at 
:360 -- `substring(name, 2)` parses as `UnresolvedFunction(nArgs=2)` while :137 
requires 3. And several shapes never become an `UnresolvedFunction` at all, so 
`extractFunctionReferences` returns an empty set for them.
   
   Could we lift the bind+resolve transform out of `evaluateExpressionOnRow` 
and reject here when the resolved tree still contains an `Unevaluable` node? 
That would cover arity, aggregates and subqueries in one check, and let 
`SupportedFunctionNames` go away instead of being hand-maintained alongside the 
41 match arms.
   
   <details><summary>Verified instances (parsed with Spark 3.5.5 
<code>CatalystSqlParser</code>)</summary>
   
   Wrong arity: accepted by the name check, dropped at eval.
   
   ```
   substring(name, 2)  -> UnresolvedFunction nameParts=[substring] nArgs=2   
(:137 requires 3)
   substr(name, 2)     -> UnresolvedFunction nameParts=[substr]    nArgs=2
   ceil(price, 1)      -> UnresolvedFunction nameParts=[ceil]      nArgs=2   
(:161 requires 1)
   floor(price, 1)     -> UnresolvedFunction nameParts=[floor]     nArgs=2
   size(arr, 1)        -> UnresolvedFunction nameParts=[size]      nArgs=2
   ```
   
   Invisible to the name check entirely: function set empty, tree still 
unevaluable.
   
   ```
   any_value(id) = 1   fnsSeen=[]  unevaluable=true
   first(id) = 1       fnsSeen=[]  unevaluable=true
   last(id) = 1        fnsSeen=[]  unevaluable=true
   id = (select 1)     fnsSeen=[]  unevaluable=true
   exists (select 1)   fnsSeen=[]  unevaluable=true
   ```
   </details>



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -120,21 +120,26 @@ 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("validateFilterExpression rejects functions outside the resolution 
table") {
+    // Direct evaluation still treats an unresolved function as a non-match, 
but procedure callers
+    // validate first so unsupported functions are reported instead of 
silently dropping every row.
     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))
+    val unsupported = HoodieProcedureFilterUtils.validateFilterExpression(
+      "concat(name, 'x') = 'a1x' OR instr(name, 'a') = 1", scalarSchema, spark)
+    assert(unsupported.isLeft)
+    val unsupportedMsg = unsupported.fold(identity, _ => "")
+    assert(unsupportedMsg.contains("Unsupported functions: concat, instr"))
+    assert(unsupportedMsg.contains("Supported functions:"))
+    assert(unsupportedMsg.contains("upper"))
     // 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(Right(()))(
+      HoodieProcedureFilterUtils.validateFilterExpression("upper(name) = 
'A1'", scalarSchema, spark))
     assertResult(Seq(scalarRows.head))(keep(scalarRows, "upper(name) = 'A1'", 
scalarSchema))

Review Comment:
   **nit:** Feel free to ignore. This line is byte-identical to :156 in 
"evaluateFilter resolves string functions", and the new validate control just 
above already carries the "upper is supported" claim.
   
   Could we drop it?



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -120,21 +120,26 @@ 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("validateFilterExpression rejects functions outside the resolution 
table") {
+    // Direct evaluation still treats an unresolved function as a non-match, 
but procedure callers
+    // validate first so unsupported functions are reported instead of 
silently dropping every row.
     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))
+    val unsupported = HoodieProcedureFilterUtils.validateFilterExpression(
+      "concat(name, 'x') = 'a1x' OR instr(name, 'a') = 1", scalarSchema, spark)
+    assert(unsupported.isLeft)
+    val unsupportedMsg = unsupported.fold(identity, _ => "")
+    assert(unsupportedMsg.contains("Unsupported functions: concat, instr"))
+    assert(unsupportedMsg.contains("Supported functions:"))
+    assert(unsupportedMsg.contains("upper"))
     // if() is parsed as a function call and hits the same gap, while the 
equivalent CASE WHEN is

Review Comment:
   **minor:** Not blocking. `if(a, b, c)` is the case #19638 leads with, and 
this change does now reject it, but only the old `evaluateFilter` `Seq.empty` 
assertion is kept, so the fix itself is unpinned. The comment also now says the 
opposite of the new behavior -- `if()` no longer "hits the same gap" at the 
validation layer.
   
   Could we add 
`assert(HoodieProcedureFilterUtils.validateFilterExpression("if(name = 'a1', 
true, false)", scalarSchema, spark).isLeft)` and reword the comment?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -481,6 +492,12 @@ object HoodieProcedureFilterUtils {
     }
   }
 
+  private def extractFunctionReferences(expression: Expression): Set[String] = 
expression match {
+    case unresolved: UnresolvedFunction =>
+      Set(unresolved.nameParts.head.toLowerCase) ++ 
unresolved.children.flatMap(extractFunctionReferences)

Review Comment:
   **minor:** Not blocking. `toLowerCase` here uses the JVM default locale. 
Under `-Duser.language=tr`, `"ISNULL"` lowercases to a dotless-i form that 
misses the set, so a genuinely supported function gets hard-rejected -- 10 of 
the 41 names contain an `i`. #19835 fixed this same class in `a7deb61f7426`, 
which is this PR's parent commit, and `style/scalastyle.xml:81` has 
`RegexChecker` disabled so nothing flags it.
   
   Could we use `Locale.ROOT` here (plus a `java.util.Locale` import) and align 
:100 in the same patch?
   
   ```suggestion
         Set(unresolved.nameParts.head.toLowerCase(Locale.ROOT)) ++ 
unresolved.children.flatMap(extractFunctionReferences)
   ```



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -468,9 +475,13 @@ object HoodieProcedureFilterUtils {
         val columnNames = schema.fieldNames.toSet
         val referencedColumns = extractColumnReferences(parsedExpr)
         val invalidColumns = referencedColumns -- columnNames
+        val unsupportedFunctions = extractFunctionReferences(parsedExpr) -- 
SupportedFunctionNames
 
         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(", ")}. "

Review Comment:
   **nit:** Feel free to ignore. A qualified call reports the wrong token: 
`spark_catalog.default.upper(name)` parses with `nameParts = [spark_catalog, 
default, upper]`, so `nameParts.head` at :497 makes this read `Unsupported 
functions: spark_catalog`.
   
   Could we report `nameParts.mkString(".")` so the message names the call the 
user actually wrote?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -39,6 +39,13 @@ import scala.util.{Failure, Success, Try}
  */
 object HoodieProcedureFilterUtils {
 
+  private val SupportedFunctionNames: Set[String] = Set(
+    "abs", "array_contains", "array_size", "between", "bigint", "ceil", 
"ceiling", "coalesce",
+    "date_format", "datediff", "day", "dayofmonth", "double", "floor", "hour", 
"integer", "int",

Review Comment:
   **major:** `hour` and `date_format` are listed as supported, but this file's 
own test pins that neither can ever evaluate. 
`TestHoodieProcedureFilterUtils.scala:196-203` ("evaluateFilter cannot evaluate 
time-zone-aware timestamp functions") asserts `Seq.empty` for `hour(t) = 12` 
and `date_format(t, 'yyyy') = '2024'`, because the util binds and evals without 
the analyzer so the time zone is never resolved. The message therefore tells a 
user the function is supported and they then get the silent empty result of 
#19638.
   
   Could we drop both names from the set?
   
   ```suggestion
       "datediff", "day", "dayofmonth", "double", "floor", "integer", "int",
   ```



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -120,21 +120,26 @@ 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("validateFilterExpression rejects functions outside the resolution 
table") {

Review Comment:
   **minor:** Not blocking, a placement point. After the rename, 5 of the 8 
assertions here still call `keep` (i.e. `evaluateFilter`) -- concat, instr, if, 
case-when, upper -- so the name no longer describes the body, and the two 
`validateFilterExpression` assertions sit apart from the existing validate test 
at :297.
   
   Could we move the validate assertions into "validateFilterExpression accepts 
valid references and rejects unknown ones" and leave this one under an 
evaluateFilter-shaped name?



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -120,21 +120,26 @@ 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("validateFilterExpression rejects functions outside the resolution 
table") {
+    // Direct evaluation still treats an unresolved function as a non-match, 
but procedure callers
+    // validate first so unsupported functions are reported instead of 
silently dropping every row.
     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))
+    val unsupported = HoodieProcedureFilterUtils.validateFilterExpression(
+      "concat(name, 'x') = 'a1x' OR instr(name, 'a') = 1", scalarSchema, spark)

Review Comment:
   **major:** Nothing asserts the new message reaches a user through a `call` 
statement, which is how #19638 reproduces. The sibling branch already has that 
coverage: `TestShowCleansProcedures.scala:634-638` asserts `"Invalid column 
references: nonexistent_col"` end to end, in a fixture that needs no data 
insert.
   
   Could we add one line beside it?
   
   ```scala
   checkExceptionContain(s"""call show_clean_plans(table => '$tableName', 
filter => "concat(action, 'x') = 'cleanx'")""")(
     "Unsupported functions: concat")
   ```



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -120,21 +120,26 @@ 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("validateFilterExpression rejects functions outside the resolution 
table") {
+    // Direct evaluation still treats an unresolved function as a non-match, 
but procedure callers
+    // validate first so unsupported functions are reported instead of 
silently dropping every row.
     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))
+    val unsupported = HoodieProcedureFilterUtils.validateFilterExpression(

Review Comment:
   **nit:** Feel free to ignore. 
`HoodieProcedureFilterUtils.validateFilterExpression(..., scalarSchema, spark)` 
is now spelled out 7 times in this file, while the sibling API already has the 
`keep` helper at :37.
   
   Could we add `private def validate(expr: String, schema: StructType = 
scalarSchema) = HoodieProcedureFilterUtils.validateFilterExpression(expr, 
schema, spark)` next to it?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to