dongjoon-hyun commented on code in PR #59217:
URL: https://github.com/apache/spark/pull/59217#discussion_r4208792783


##########
sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala:
##########
@@ -179,6 +181,34 @@ abstract class UpdateTableSuiteBase extends 
RowLevelOperationSuiteBase {
     checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 1)
   }
 
+  test("SPARK-59962: update with a condition reading a computed value twice") {
+    // BETWEEN reads `salary + pk` twice. The condition keeps no `With`, and 
the group filter
+    // condition, only evaluated in the runtime group filter, keeps it for 
that filter to
+    // memoize. One that gains nothing from memoizing is still inlined there, 
so a
+    // condition that folds to true leaves a true group filter condition.
+    createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
+      """{ "pk": 1, "salary": 100, "dep": "hr" }
+        |{ "pk": 2, "salary": 200, "dep": "software" }
+        |{ "pk": 3, "salary": 300, "dep": "hr" }
+        |""".stripMargin)
+
+    val (cond, groupFilterCond) = executeAndKeepConditions {
+      sql(s"UPDATE $tableNameAsString SET dep = 'x' WHERE (salary + pk) 
BETWEEN 150 AND 250")
+    }
+    assert(!cond.containsPattern(WITH_EXPRESSION), s"condition: $cond")
+    assert(groupFilterCond.exists(_.containsPattern(WITH_EXPRESSION)),
+      s"group filter condition: $groupFilterCond")
+    checkAnswer(
+      sql(s"SELECT * FROM $tableNameAsString"),
+      Row(1, 100, "hr") :: Row(2, 200, "x") :: Row(3, 300, "hr") :: Nil)

Review Comment:
   Minor: this test checks the shape of `groupFilterCondition`, but not that 
the runtime group filter still works with the `With` kept in it, which is what 
f397054 changes. The DELETE test pins that with 
`checkDeleteMetrics(numDeletedRows = 2, numCopiedRows = 1)`, but a group-based 
UPDATE builds the filter another way: `buildMatchingRowsPlan` remaps the 
attributes of `cond` through `attrMap` 
(RowLevelOperationRuntimeGroupFiltering.scala:110-115) before 
`optimizeSubqueries` hoists the `With`. Here only pk 2 matches, so with the 
filter the group-based run should copy no rows, and without it, both `hr` rows. 
The neighboring tests call `checkUpdateMetrics`. The second UPDATE checks only 
its group filter condition, not the rows it updates. Could this call 
`checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)` after the first 
UPDATE, and `checkAnswer` after the second?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +71,34 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
         // changed anything: `mapExpressions` and `withNewChildren` preserve 
reference equality when
         // they rewrite nothing, which is what makes this detectable.
         if ((rewrittenAgg eq agg) && (rewrittenProj eq proj)) p else 
rewrittenProj
+      // A row-level write reads its `groupFilterCondition` only in the 
runtime group filter, which
+      // puts it in a filter over the table and optimizes it there. A `With` 
in it is treated as one
+      // in a conditional branch: the definitions that gain nothing from 
memoizing are inlined, so a
+      // condition that folds to true still injects no filter, and the rest 
are left for that
+      // filter. Only `condition` is substituted as below.
+      case rd: ReplaceData =>
+        rowLevelConditions(rd.condition, rd.groupFilterCondition).map { case 
(cond, groupFilter) =>
+          rd.copy(condition = cond, groupFilterCondition = groupFilter)
+        }.getOrElse(rd)
+      case wd: WriteDelta =>
+        rowLevelConditions(wd.condition, wd.groupFilterCondition).map { case 
(cond, groupFilter) =>
+          wd.copy(condition = cond, groupFilterCondition = groupFilter)
+        }.getOrElse(wd)
+      // A command's own expressions are not evaluated over its children's 
rows: most are stored as
+      // metadata or turned into source predicates, neither of which can hold 
a memoized value, and
+      // a `Call` must see foldable arguments over its `ResolvedProcedure` 
(`InvokeProcedures`). A
+      // definition hoisted into a child would leave them reading a column 
only that `Project`
+      // produces, or leave the command over a child its planner, or 
`InvokeProcedures`, does not
+      // expect, so a deterministic one is substituted whatever it costs (see 
`canSubstitute`).
+      // The copies compound with nesting, as with 
`spark.sql.alwaysInlineCommonExpr`, and where
+      // the expression is evaluated after all, as a CHECK predicate stored by 
ALTER TABLE is at
+      // every later write, each copy is evaluated. Analysis lets no command 
but `CreateVariable`
+      // hold a nondeterministic expression; a `Call` can hold one, as 
`InvokeProcedures` optimizes
+      // it before analysis checks it, and then rejects it as not foldable. A 
nondeterministic
+      // definition read more than once keeps its `With`. `CreateVariable` is 
left out: its default
+      // is evaluated rather than stored, so a definition there should be 
memoized.
+      case c @ (_: Command | _: Call) if !c.isInstanceOf[CreateVariable] =>

Review Comment:
   Minor: this comment, like the scaladoc at L33, puts `Call` with the commands 
whose expressions are stored or translated rather than evaluated. But 
`InvokeProcedures` evaluates every argument (`args.map(_.eval())`, 
InvokeProcedures.scala:82), and "its default is evaluated rather than stored" 
is the reason this comment gives for leaving `CreateVariable` out. Its 
expressions aren't evaluated over its children's rows either, since those 
children are just the `ResolvedIdentifier` names. What sets `Call` apart is 
that its arguments must fold to literals over a fixed `ResolvedProcedure`, so 
each copy is folded once at planning time. The consequence is that an impure 
foldable in an argument, such as `aes_encrypt` over literals, is folded per 
copy. Could the comment and the scaladoc give that as the reason for `Call`, 
rather than putting it with the stored expressions?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -222,12 +255,18 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
    * as an `aes_encrypt` with no IV still gets substituted and folded per 
copy. That predates this
    * rule keeping a `With` -- the condition it replaced was no stricter -- and 
belongs either on
    * those expressions or in one purity predicate shared with 
`isSafeToDuplicate`.
+   *
+   * Without `requireCheap`, as for a command's expressions 
(`inlineCommandWiths`), the cost is not
+   * asked, so the gap widens to an impure definition that reads a column, 
such as an
+   * `aes_encrypt` over one.
    */
   private def canSubstitute(
       child: Expression,
       id: CommonExpressionId,
-      multiplyReferenced: Set[CommonExpressionId]): Boolean = {
-    !multiplyReferenced.contains(id) || (CollapseProject.isCheap(child) && 
child.deterministic)
+      multiplyReferenced: => Set[CommonExpressionId],
+      requireCheap: Boolean = true): Boolean = {
+    (child.deterministic && (!requireCheap || CollapseProject.isCheap(child))) 
||

Review Comment:
   Nit: putting the `deterministic`/`isCheap` term first makes 
`CollapseProject.isCheap` run for every definition on the generic and 
conditional-branch paths. That includes definitions read once, which the old 
order settled with the set lookup. There `multiplyReferenced` is already a 
strict value, so only `inlineCommandWiths` gains from the by-name parameter. It 
is a small tree walk, so this is cosmetic. Could the old order stay when 
`requireCheap` is set, e.g. `if (requireCheap) !multiplyReferenced.contains(id) 
|| (CollapseProject.isCheap(child) && child.deterministic) else 
child.deterministic || !multiplyReferenced.contains(id)`?



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


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to