LuciferYang commented on code in PR #59217:
URL: https://github.com/apache/spark/pull/59217#discussion_r4192296567


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ 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 command's own expressions are not evaluated over its children's 
rows: most are stored as
+      // metadata or turned into source predicates. 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 does not expect, so it is substituted instead. Analysis lets 
no command but
+      // `CreateVariable` hold a nondeterministic expression; one that does 
keeps its `With` rather
+      // than be read twice. `CreateVariable` is left out: its default is 
evaluated rather than
+      // stored, so a definition there should be memoized, not inlined.
+      case c: Command if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+          case w: With => inlineDefs(w)(_.child.deterministic)

Review Comment:
   `isSafeToDuplicate` only accepts literal trees, so any definition that reads 
a column fails it. With it as the selector the CHECK and DELETE tests fail 
again: the `With` stays in an expression that is stored or translated into a 
source predicate, and neither can hold a memoized value. So the gap is the one 
`canSubstitute` already documents for impure expressions that declare 
themselves deterministic; the comment on this case now says so and points there.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ 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 command's own expressions are not evaluated over its children's 
rows: most are stored as
+      // metadata or turned into source predicates. 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 does not expect, so it is substituted instead. Analysis lets 
no command but
+      // `CreateVariable` hold a nondeterministic expression; one that does 
keeps its `With` rather
+      // than be read twice. `CreateVariable` is left out: its default is 
evaluated rather than
+      // stored, so a definition there should be memoized, not inlined.
+      case c: Command if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {

Review Comment:
   Measured with a counting UDF over 4 rows in 3 groups on a group-based table: 
`WHERE f(id) BETWEEN 2 AND 3` calls it 11 times with the runtime group filter 
on and 4 with it off. Written out as `f(id) >= 2 AND f(id) <= 3` it is 14 both 
before and after this PR, and before this PR the BETWEEN form failed with the 
filter on. Keeping a `With` in that filter would need the condition in two 
forms, since the same expression is also translated for pushdown, so I noted 
the trade-off in the comment rather than change it here.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala:
##########
@@ -486,6 +486,48 @@ class RewriteWithExpressionSuite extends PlanTest {
     )
   }
 
+  test("SPARK-59962: a With in a command's condition is inlined instead of 
hoisted") {
+    val a = testRelation.output.head
+    // The condition is translated into source predicates, not evaluated over 
the child's rows, so a
+    // `Project` under the command would sit where the planner expects the 
table.
+    val condition = With(a + a) { case Seq(ref) => ref < 10 && ref > 0 }
+    comparePlans(
+      Optimizer.execute(DeleteFromTable(testRelation, condition)),
+      DeleteFromTable(testRelation, (a + a) < 10 && (a + a) > 0))
+
+    // Nor is one in a conditional branch kept: a `With` translates to no 
source predicate.
+    val inBranch = If(a > 0, With(a + a) { case Seq(ref) => ref < 10 && ref > 
0 }, Literal(false))
+    comparePlans(
+      Optimizer.execute(DeleteFromTable(testRelation, inBranch)),
+      DeleteFromTable(testRelation, If(a > 0, (a + a) < 10 && (a + a) > 0, 
Literal(false))))
+  }
+
+  test("SPARK-59962: a nondeterministic definition in a command keeps its 
With") {
+    // Analysis keeps nondeterministic expressions out of every command but 
`CreateVariable`, so
+    // this only pins that the rule would not read such a definition twice.
+    val a = testRelation.output.head
+    val delete = DeleteFromTable(testRelation, With(Rand(Literal(0L)) + a) { 
case Seq(ref) =>
+      ref < 10 && ref > 0
+    })
+    assert(Optimizer.execute(delete) == delete)
+  }
+
+  test("SPARK-59962: a definition in a DECLARE VARIABLE default is not inlined 
like a command's") {
+    // The default is evaluated rather than stored, so a definition there that 
is worth memoizing
+    // must not be substituted at each of its references the way a command's 
is. Until SPARK-59954
+    // keeps it in the default, the generic case pre-evaluates it under the 
variable names instead;
+    // either way there is at most one copy.
+    val ident = ResolvedIdentifier(FakeSystemCatalog, 
Identifier.of(Array("session"), "v"))
+    // Deterministic but not cheap: the command case would inline it.
+    val plusOne = ScalaUDF((i: Int) => i + 1, IntegerType, Seq(Literal(1)), 
udfName = Some("f"))
+    val declare = CreateVariable(
+      Seq(ident), DefaultValueExpression(With(plusOne) { case Seq(ref) => ref 
* ref }, "v"),
+      replace = true)
+    val copies = Optimizer.execute(declare).expressions
+      .map(_.collect { case u: ScalaUDF => u }.size).sum
+    assert(copies <= 1, s"the definition was substituted at $copies 
references")

Review Comment:
   Done. It counts over the whole plan with `collectWithSubqueries` and asserts 
`== 1`; without the `CreateVariable` guard it counts 2.



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala:
##########
@@ -259,6 +259,21 @@ abstract class DeleteFromTableSuiteBase extends 
RowLevelOperationSuiteBase {
     checkDeleteMetrics(numDeletedRows = 1, numCopiedRows = 1)
   }
 
+  test("SPARK-59962: delete with a condition reading a computed value twice") {
+    // BETWEEN reads `id + pk` twice. A group-based delete pushes the 
condition down to the scan
+    // and reuses it for runtime group filtering, where a column pre-evaluated 
under the write is
+    // not found.
+    createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
+      """{ "pk": 1, "id": 1, "dep": "hr" }
+        |{ "pk": 2, "id": 2, "dep": "software" }
+        |{ "pk": 3, "id": 3, "dep": "hr" }
+        |""".stripMargin)
+
+    sql(s"DELETE FROM $tableNameAsString WHERE (id + pk) BETWEEN 2 AND 4")

Review Comment:
   Done. Added a row in a `finance` group the condition does not match, and the 
test calls `checkDeleteMetrics(numDeletedRows = 2, numCopiedRows = 1)`. With 
the runtime group filter disabled it fails with 2 copied rows.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +67,18 @@ 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 command's own expressions are not evaluated over its children's 
rows: most are stored as
+      // metadata or turned into source predicates. 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 does not expect, so it is substituted instead. Analysis lets 
no command but
+      // `CreateVariable` hold a nondeterministic expression; one that does 
keeps its `With` rather
+      // than be read twice. `CreateVariable` is left out: its default is 
evaluated rather than
+      // stored, so a definition there should be memoized, not inlined.
+      case c: Command if !c.isInstanceOf[CreateVariable] &&

Review Comment:
   Agreed that this belongs on the command, and `CheckAnalysis` should read the 
same property. It changes the `Command` API and both rules, so I'd rather do it 
separately: SPARK-59994.



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