hudi-agent commented on code in PR #19853:
URL: https://github.com/apache/hudi/pull/19853#discussion_r3970231585


##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -389,6 +417,76 @@ object HoodieProcedureFilterUtils {
     }
   }
 
+  // Resolves a function not covered by the hardcoded table above via Spark's 
own FunctionRegistry,
+  // then checks the result is actually usable outside a real query plan - 
both steps a plain
+  // lookupFunction call skips or can't tell on its own. Anything that isn't 
falls through to the
+  // existing rejection path (see #19850) instead of letting eval() throw 
silently.
+  private def resolveViaFunctionRegistry(unresolvedFunc: UnresolvedFunction, 
sparkSession: SparkSession): Expression = {
+    Try {
+      val castedResolved = applyImplicitCasts(lookupBuiltin(unresolvedFunc, 
sparkSession))

Review Comment:
   🤖 This first `checkInputDataTypes` runs before the argument subtree has been 
widened, so a registry function over a mixed-type arithmetic argument gets 
rejected: `sqrt(ts + 1) > 0` or `concat(name, ts + 1) = 'a11001'` (ts is Long, 
1 is Int) leaves `Add` unresolved, `ImplicitTypeCasts`/`ConcatCoercion` skip on 
`!childrenResolved`, and the Long child fails the input-type check -> 
"Unsupported functions: sqrt", while the same shape through the hardcoded table 
(`abs(ts + 1)`) works because pass three widens afterwards. Would running 
`applyCoercionRules` over `unresolvedFunc.arguments` before the lookup close 
that gap?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala:
##########
@@ -354,15 +371,92 @@ class TestHoodieProcedureFilterUtils extends 
HoodieSparkProcedureTestBase {
     assertResult(Seq(rows(1)))(keep(rows, "`50% overlap` > 15", schema))
   }
 
-  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(Seq.empty)(keep(scalarRows, "if(name = 'a1', true, false)", 
scalarSchema))
+  test("evaluateFilter resolves functions outside the hardcoded table via 
FunctionRegistry") {
+    // Functions missing from the hardcoded table now fall back to Spark's own 
FunctionRegistry
+    // instead of being rejected as unsupported. See #19852.
+    assertKeeps(scalarRows, "concat(name, 'x') = 'a1x'", Seq(scalarRows.head))
+    assertKeeps(scalarRows, "instr(name, 'a') = 1", Seq(scalarRows.head))
+    assertKeeps(scalarRows, "if(name = 'a1', true, false)", 
Seq(scalarRows.head))
     assertResult(Seq(scalarRows.head))(
       keep(scalarRows, "case when name = 'a1' then true else false end", 
scalarSchema))
     // Or short-circuits on the resolved side, which is what the 
unresolved-operand guard preserves.
-    assertResult(Seq(scalarRows.head))(
-      keep(scalarRows, "id = 1 OR concat(name, 'x') = 'a1x'", scalarSchema))
+    assertKeeps(scalarRows, "id = 1 OR concat(name, 'x') = 'a1x'", 
Seq(scalarRows.head))
+
+    // RuntimeReplaceable builtins (nvl, left, right, ...) resolve to a 
placeholder node that
+    // FunctionRegistry.lookupFunction doesn't substitute on its own - make 
sure we unwrap it
+    // rather than letting eval() blow up on the raw placeholder.
+    assertKeeps(scalarRows, "nvl(name, 'z') = 'a1'", Seq(scalarRows.head))
+    assertKeeps(scalarRows, "left(name, 1) = 'a'", Seq(scalarRows.head))
+    assertKeeps(scalarRows, "right(name, 1) = '1'", Seq(scalarRows.head))
+
+    // A hardcoded-table entry called with an arity the table doesn't handle 
(substring only
+    // handles 3 args) should still fall back to the registry instead of 
getting stuck.
+    assertKeeps(scalarRows, "substring(name, 2) = '1'", Seq(scalarRows.head))
+
+    // The rejection message for multiple unknown functions lists every name, 
sorted.
+    assert(validate("no_such_fn(name) = 'x' OR other_missing(name) = 1")
+      .left.exists(_ == "Unsupported functions: no_such_fn, other_missing"))
+
+    // A 3+ part name (catalog.db.func) isn't safe to look up by bare function 
name alone - make
+    // sure it's rejected rather than silently resolved against a same-named 
function elsewhere.
+    assert(validate("some_catalog.some_db.upper(name) = 'A1'").isLeft)
+    assertResult(Seq.empty)(keep(scalarRows, "some_catalog.some_db.upper(name) 
= 'A1'", scalarSchema))
+    // Same story for a 2-part db-qualified name: builtins register with no 
database, so
+    // FunctionRegistry has no "default.upper" to find, and guessing by 
dropping the qualifier
+    // would risk the same wrong-function-match problem as the 3+ part case.
+    assert(validate("default.upper(name) = 'A1'").isLeft)
+    assertResult(Seq.empty)(keep(scalarRows, "default.upper(name) = 'A1'", 
scalarSchema))
+  }
+
+  test("evaluateFilter still rejects aggregate/generator/nondeterministic 
functions resolved via FunctionRegistry") {
+    // Aggregate functions resolve fine as expressions but can't be eval()'d 
per row - make sure
+    // those still go through the existing #19850 rejection path instead of 
silently resolving to
+    // a broken, always-false filter. Same story for generators (explode only 
makes sense in a
+    // projection) and non-deterministic functions (rand()/uuid() rely on 
per-partition
+    // initialization this evaluator never does). any_value is covered 
separately below - the
+    // parser lowers it straight to an AggregateExpression before it ever 
reaches this guard.
+    // max(id) is an unambiguous AggregateFunction case (no decimal-literal 
argument to complicate
+    // why it's rejected, unlike percentile's 0.5), so it's what actually pins 
the guard clause.
+    assert(validate("max(id) > 0").left.exists(_.contains("Unsupported 
functions: max")))
+    assertResult(Seq.empty)(keep(scalarRows, "max(id) > 0", scalarSchema))
+    assert(validate("percentile(id, 0.5) = 1").isLeft)
+    assert(validate("explode(array(1, 2)) = 
1").left.exists(_.contains("Unsupported functions: explode")))
+    assert(validate("rand() = 1").isLeft)
+    assert(validate("uuid() = 'x'").isLeft)
+    assertResult(Seq.empty)(keep(scalarRows, "rand() = 1", scalarSchema))
+    // monotonically_increasing_id/input_file_name are also Nondeterministic, 
so the same
+    // deterministic check catches them without needing their own case.
+    assert(validate("monotonically_increasing_id() = 1").isLeft)
+    assert(validate("input_file_name() = 'x'").isLeft)
+    // current_timestamp is deterministic-at-eval-time (Spark computes it 
directly rather than
+    // requiring rule substitution), so it resolves and evaluates for real 
instead of needing
+    // denylist treatment. current_date doesn't share that: it's a 
TimeZoneAwareExpression that
+    // stays unresolved without a session zone the same way hour(t) does 
above, not because of
+    // anything this guard rejects.
+    assertResult(scalarRows)(keep(scalarRows, "current_timestamp() > t", 
scalarSchema))

Review Comment:
   🤖 On Spark 4.0.2 `CurrentTimestampLike` implements `FoldableUnevaluable` 
(and `Unevaluable extends FoldableUnevaluable`, not the reverse), so its `eval` 
throws, the foldable probe in `isUsableOutsideQueryPlan` rejects it, and `keep` 
returns `Seq.empty` here rather than `scalarRows`. The 4.0 CI lane is commented 
out right now so this won't surface, but under `-Pspark4.0` this assertion 
fails - could it be gated like the `gteqSpark4_0` check at line 216, or assert 
the rejection on 4.0?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -389,6 +417,76 @@ object HoodieProcedureFilterUtils {
     }
   }
 
+  // Resolves a function not covered by the hardcoded table above via Spark's 
own FunctionRegistry,
+  // then checks the result is actually usable outside a real query plan - 
both steps a plain

Review Comment:
   🤖 nit: `resolveOrFallback` takes both the already-computed 
`hardcodedResolved` and the original `unresolvedFunc` just to re-check the same 
match — might be worth renaming the params (e.g. `firstAttempt`/`original`) so 
it's clearer at the call site which is which.
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/HoodieProcedureFilterUtils.scala:
##########
@@ -389,6 +417,76 @@ object HoodieProcedureFilterUtils {
     }
   }
 
+  // Resolves a function not covered by the hardcoded table above via Spark's 
own FunctionRegistry,
+  // then checks the result is actually usable outside a real query plan - 
both steps a plain
+  // lookupFunction call skips or can't tell on its own. Anything that isn't 
falls through to the
+  // existing rejection path (see #19850) instead of letting eval() throw 
silently.
+  private def resolveViaFunctionRegistry(unresolvedFunc: UnresolvedFunction, 
sparkSession: SparkSession): Expression = {
+    Try {
+      val castedResolved = applyImplicitCasts(lookupBuiltin(unresolvedFunc, 
sparkSession))
+      // Checked here, on the raw wrapper, before unwrapping: a 
RuntimeReplaceable wrapper's own
+      // declared input-type contract (nvl needing matching operand types, 
split_part needing
+      // string/string/int) is otherwise discarded once unwrapped to a form 
with a weaker or
+      // absent contract of its own.
+      if (!castedResolved.checkInputDataTypes().isSuccess) {
+        unresolvedFunc
+      } else {
+        val finalized = finalizeRegistryResolution(castedResolved)
+        if (isUsableOutsideQueryPlan(finalized)) finalized else unresolvedFunc
+      }

Review Comment:
   🤖 nit: `resolveViaFunctionRegistry` chains lookup, implicit-casts, 
input-type check, unwrap/coercion, and a second usability check all in one Try 
block — have you considered splitting the "resolve" step from the "is this 
usable standalone" check so each concern is easier to follow/test on its own?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



-- 
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