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


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -147,6 +147,21 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  test("SPARK-59962: Predicate of a check reading a computed value twice") {
+    // NULLIF reads `id + 1` twice. The predicate stored in the catalog is 
built from the check as
+    // the optimizer leaves it, so it must not refer to a column pre-evaluated 
under the command.
+    withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+      sql(s"CREATE TABLE $t (id bigint, data string) $defaultUsing")
+      sql(s"ALTER TABLE $t ADD CONSTRAINT c1 CHECK (nullif(id + 1, 3) > 0)")
+      val constraint = getCheckConstraint(loadTable(nonPartitionCatalog, "ns", 
"tbl"))
+      assert(constraint.predicateSql() == "nullif(id + 1, 3) > 0")
+      assert(constraint.predicate() != null)

Review Comment:
   **This assertion fails in the scheduled Non-ANSI build: `id + 1` translates 
into a V2 expression only in ANSI mode.**
   
   `build_non_ansi.yml` (L42) runs the sql tests with 
`SPARK_ANSI_SQL_MODE=false`, which turns `spark.sql.ansi.enabled` off by 
default (`SQLConf.scala` L6014). `id + 1` is then an `Add` in 
`EvalMode.LEGACY`, and `V2ExpressionBuilder` translates `Add` only in ANSI mode 
(L80), so with the definition inlined `toV2Constraint` stores a null predicate. 
Before this PR the predicate read `_common_expr_N`, which translates in both 
modes, so the failure is new. Neither this suite nor `CommandSuiteBase` pins 
ANSI, unlike `DataSourceV2SQLSuite` (L62-63), where the new DELETE test is 
fine. The PR's CI runs with ANSI on, so this shows up only after the merge; 
ec120fddcf6 had to add the same guard to earlier tests of V2 translation.
   
   Could the body, including the ALTER, run under 
`withSQLConf(SQLConf.ANSI_ENABLED.key -> "true")`, or assert the enforcement 
with INSERTs instead (my comment on L150), which doesn't depend on the mode?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see
+      // `canSubstitute`). Analysis lets no command but `CreateVariable` hold 
a nondeterministic
+      // expression; one 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 if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {

Review Comment:
   **This also inlines `groupFilterCondition`, which nothing translates: only 
the runtime group filter reads it, and it evaluates it.**
   
   This is about item 2 of my previous review and the reply that keeping a 
`With` in that filter "would need the condition in two forms, since the same 
expression is also translated for pushdown". `ReplaceData` and `WriteDelta` 
already hold two forms. `condition` is what 
`GroupBasedRowLevelOperationScanPlanning` pushes (L48, L56) and 
`OptimizeMetadataOnlyDeleteFromTable` translates (L116). `groupFilterCondition` 
(`v2Commands.scala` L390, L480), set from the same `cond` by 
`RewriteDeleteFromTable` (L92) and `RewriteUpdateTable` (L82, L117), is read 
only by `RowLevelOperationRuntimeGroupFiltering` (L56), which wraps it in 
`Filter(cond, relation)` (L105) and runs `optimizeSubqueries` on it (L95), and 
so this rule again. A `With` left there would be pre-evaluated under that 
filter, and `spark.sql.optimizer.avoidDoubleFilterEval`, on by default, keeps 
an expensive alias such as a Scala UDF memoized.
   
   Inlining it also regresses a statement that works on master. On a 
group-based table, `DELETE FROM t WHERE CASE WHEN dep = 'hr' THEN my_udf(id) 
BETWEEN 2 AND 4 ELSE false END` keeps a memoizing `With` in both slots on 
master (the `ConditionalExpression` path, L391-402), so the runtime group 
filter calls the UDF once per row. Here it is called up to twice per row that 
reaches the branch, and subexpression elimination doesn't merge two uses in one 
branch (`EquivalentExpressions.scala` L171, L181).
   
   Could this case inline only `condition` of `ReplaceData` and `WriteDelta`, 
and leave `groupFilterCondition` to the rewrite of the injected filter? That 
would also save the runtime filter's extra calls in the top-level BETWEEN you 
measured.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -147,6 +147,21 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  test("SPARK-59962: Predicate of a check reading a computed value twice") {

Review Comment:
   Minor: this test checks the shape of the stored predicate, but not what 
users hit on master. From reading the code, after this ALTER every later write 
to the table fails there: `ResolveTableConstraints` builds the write-time check 
from `c.predicate()` before `predicateSql` (L77), and 
`V2ExpressionUtils.toCatalyst` turns the stored `_common_expr_1` into an 
`UnresolvedAttribute` (`V2ExpressionUtils.scala` L260), so INSERT, UPDATE, 
MERGE and a group-based DELETE fail analysis with UNRESOLVED_COLUMN. The 
description mentions only the stored predicate.
   
   Could the test also insert a row the check accepts and one it rejects, e.g. 
`(2, 'a')`, where `nullif(3, 3)` is NULL and a CHECK accepts it, and `(-5, 
'b')`, which fails with CHECK_CONSTRAINT_VIOLATION? That fails on master and 
passes here in both ANSI modes.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/GeneratedColumnWriteSuite.scala:
##########
@@ -456,6 +456,23 @@ class GeneratedColumnWriteSuite extends QueryTest with 
DatasourceV2SQLBase {
     }
   }
 
+  test("SPARK-59962: CREATE TABLE with a generation expression reading a value 
twice") {

Review Comment:
   Minor: REPLACE TABLE fails the same way on master and is fixed by this PR as 
well, but isn't tested. `ResolveCatalogs` gives `ReplaceTable` the same 
column-output `ResolvedIdentifier` as `CreateTable` (L123-125), `ReplaceTable` 
is a `UnaryCommand`, and `DataSourceV2Strategy` plans it only over a 
`ResolvedIdentifier` (L312-313), so on master `CREATE OR REPLACE TABLE 
testcat.my_tab (a INT, b INT, c INT GENERATED ALWAYS AS (nullif(a + b, 3))) 
USING foo` should hit "No plan for ReplaceTable". If SPARK-59994 turns the 
`CreateVariable` check into a per-command property and misses `ReplaceTable`, 
every test would still pass. Could this test run for both `CREATE TABLE` and 
`CREATE OR REPLACE TABLE`?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated

Review Comment:
   **A CHECK predicate stored by ALTER TABLE is evaluated after all, too: every 
later write reads it back and checks it per row.**
   
   `ResolveTableConstraints` adds a `CheckInvariant` filter to each 
`V2WriteCommand` (L39-61), built from `c.predicate()` before `predicateSql` 
(L77), and `V2ExpressionUtils.toCatalyst` round-trips this PR's CASE WHEN, `+`, 
`=` and `>`. So the inlined form is what every later INSERT, UPDATE and MERGE 
evaluates, at each reference, and the catalog keeps it. For one top-level 
NULLIF, which fails on master, that costs little. But it regresses a definition 
in a branch: for `ALTER TABLE t ADD CONSTRAINT c CHECK (CASE WHEN id > 0 THEN 
nullif(id + 1, 3) > 0 ELSE true END)`, master keeps the `With` in the branch, 
`V2ExpressionBuilder` returns None for it, and every write re-parses 
`predicateSql`, where NULLIF memoizes; here the inlined predicate is stored and 
`id + 1` is computed twice per written row. The same CHECK in CREATE TABLE 
still takes the `predicateSql` path (`ResolveTableSpec.scala` L110).
   
   Could the comment name this path too, or could the predicate be built from 
the analyzed expression (my comment on `CheckConstraintSuite` L152)?
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -147,6 +147,21 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  test("SPARK-59962: Predicate of a check reading a computed value twice") {
+    // NULLIF reads `id + 1` twice. The predicate stored in the catalog is 
built from the check as
+    // the optimizer leaves it, so it must not refer to a column pre-evaluated 
under the command.
+    withNamespaceAndTable("ns", "tbl", nonPartitionCatalog) { t =>
+      sql(s"CREATE TABLE $t (id bigint, data string) $defaultUsing")
+      sql(s"ALTER TABLE $t ADD CONSTRAINT c1 CHECK (nullif(id + 1, 3) > 0)")

Review Comment:
   Minor: a table that got such a constraint on 4.1 keeps failing every write 
after upgrading, since this PR changes only what a new ALTER stores. v4.1.0 
already hoists under `AddCheckConstraint`, builds the predicate from the 
optimized plan, and prefers it on writes (`ResolveTableConstraints.scala` 
L70-72 there). A catalog that persists the V2 predicate keeps returning `CASE 
WHEN _common_expr_1 = 3 ...`, `toCatalyst` turns `_common_expr_1` into an 
`UnresolvedAttribute` instead of failing, the `predicateSql` fallback is never 
reached, and INSERT, UPDATE and MERGE keep failing with UNRESOLVED_COLUMN. 
In-tree only `InMemoryTableCatalog` keeps the predicate object, and dropping 
and re-adding the constraint works around it, so a note may be enough. Would 
falling back to `predicateSql` when the converted predicate names a column the 
table doesn't have be worth a separate JIRA?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see
+      // `canSubstitute`). Analysis lets no command but `CreateVariable` hold 
a nondeterministic
+      // expression; one 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 if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+          case w: With =>
+            val multiplyReferenced = multiplyReferencedIds(w.child, w.defs)
+            inlineDefs(w)(d => d.child.deterministic || 
!multiplyReferenced.contains(d.id))

Review Comment:
   **Nested NULLIF/BETWEEN grow as 2^k here, since every level is inlined in 
one bottom-up pass whatever it costs.**
   
   Both builders read their definition twice (`nullExpressions.scala` L203-204, 
`Between.scala` L49-50), and the inner `With` is inlined first, so each 
enclosing definition copies the already expanded tree into both of its 
references. `nullif(nullif(nullif(trim(s), ''), 'NA'), 'N/A') IS NULL` puts 8 
copies of `trim(s)` into `condition` and `groupFilterCondition`, into the V2 
predicates handed to `canDeleteWhere` and the scan, and into a CHECK predicate 
stored by ALTER TABLE; 10 levels give 1024. V2 translation builds every copy, 
and subexpression elimination can't merge NULLIF's second read, which sits in a 
branch. The generic path stays linear by handling one level per iteration 
(L320).
   
   Most of these statements fail on master, so there this is the cost of the 
fix, the same tree `spark.sql.alwaysInlineCommonExpr=true` builds. Two that 
work on master grow as well, at planning time: a delta-based DELETE, and a 
group-based DELETE without runtime group filtering. Could the comment above say 
that the copies compound with nesting, and would it be worth keeping the `With` 
past a small size?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see
+      // `canSubstitute`). Analysis lets no command but `CreateVariable` hold 
a nondeterministic
+      // expression; one 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 if !c.isInstanceOf[CreateVariable] &&

Review Comment:
   Minor: keying this case on `Command` leaves `Call`, which isn't a `Command`, 
with the same hoisting. This relates to item 5 of my previous review and 
SPARK-59994. `Call` is a `UnaryNode` (`v2Commands.scala` L2340), 
`InvokeProcedures` runs the optimizer on it during analysis and requires 
`Call(ResolvedProcedure(...), ...)` back (`InvokeProcedures.scala` L38-42), and 
`ResolvedProcedure` has no output, so the generic path hoists a definition 
without references into a `Project` over it (L339-341). With `ProcedureSuite`'s 
`cat.ns.sum` and a Scala UDF `my_udf`, `CALL cat.ns.sum(if(nullif(my_udf(1), 2) 
IS NULL OR true, 1, 0), 1)` fails with INTERNAL_ERROR "Unexpected plan for 
optimized CALL statement": the arguments fold to literals, but the `Project` 
stays. It should work with `spark.sql.alwaysInlineCommonExpr=true`. The trigger 
is contrived, since an argument that doesn't fold fails "args must be foldable" 
anyway (L81 there), so a note in SPARK-59994, or extending this case to `Call`, 
 would be enough.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see

Review Comment:
   Minor: this points to `canSubstitute` for a narrower gap than this case has, 
and the selector at L87 is `canSubstitute` without its `isCheap` term, written 
out again. This is about the comment added for item 1 of my previous review. 
`canSubstitute`'s note (L239-243) covers an impure *foldable* definition, 
because `isCheap` admits foldables. This case also duplicates impure 
definitions that read columns, which `canSubstitute` memoizes as not cheap: 
`aes_encrypt(data, k)`, the example in that item, is a deterministic 
`StaticInvoke` that isn't foldable over a column. And since L87 doesn't go 
through `canSubstitute`, fixing purity "in one purity predicate shared with 
`isSafeToDuplicate`", as that note suggests, would leave this case behind, with 
nothing there pointing here. Could the selector go through `canSubstitute` with 
a flag that skips the cost check, or could this comment say that it includes 
definitions reading columns, and `canSubstitute` point back here?
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see
+      // `canSubstitute`). Analysis lets no command but `CreateVariable` hold 
a nondeterministic
+      // expression; one 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 if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {

Review Comment:
   Nit: `c.mapExpressions(_.transformUpWithPruning(...))` is what 
`QueryPlan.transformExpressionsUpWithPruning` does (`QueryPlan.scala` 
L250-254), so 
`c.transformExpressionsUpWithPruning(_.containsPattern(WITH_EXPRESSION)) { case 
w: With => ... }` would read like the other rules.
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala:
##########
@@ -67,6 +69,23 @@ 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, neither of which can hold 
a memoized value. 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 a
+      // deterministic one is substituted whatever it costs. Where the 
expression is evaluated
+      // after all, as by the runtime group filter of a row-level command, 
that evaluates it at
+      // every reference, and it duplicates an impure one that declares itself 
deterministic (see
+      // `canSubstitute`). Analysis lets no command but `CreateVariable` hold 
a nondeterministic
+      // expression; one 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 if !c.isInstanceOf[CreateVariable] &&
+          c.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
+        
c.mapExpressions(_.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+          case w: With =>
+            val multiplyReferenced = multiplyReferencedIds(w.child, w.defs)

Review Comment:
   Nit: this comes from the change for item 6 of my previous review. 
`multiplyReferencedIds` walks `w.child` and every definition (L258-273) for 
each `With`, but the selector reads the result only for a nondeterministic 
definition, which analysis keeps out of these commands, so it is computed for 
nothing in practice, and with nested NULLIF/BETWEEN it walks the expanded tree 
once more. A `lazy val` would skip it without changing the result.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala:
##########
@@ -2683,6 +2683,23 @@ class DataSourceV2SQLSuiteV1Filter
     }
   }
 
+  test("SPARK-59962: DeleteFrom: a condition reading a value twice reaches the 
source inlined") {
+    // BETWEEN reads `id + p` twice. Pre-evaluating it would put a `Project` 
where the planner
+    // expects the table, so the condition has to reach the source inlined.
+    val t = "testcat.ns1.ns2.tbl"
+    withTable(t) {
+      sql(s"CREATE TABLE $t (id bigint, data string, p int) USING foo 
PARTITIONED BY (id, p)")
+      sql(s"INSERT INTO $t VALUES (2L, 'a', 2), (2L, 'b', 3), (3L, 'c', 3)")
+      checkError(
+        exception = analysisException(s"DELETE FROM $t WHERE (id + p) BETWEEN 
0 AND 4"),
+        condition = "_LEGACY_ERROR_TEMP_1110",
+        parameters = Map(
+          "table" -> "testcat.ns1.ns2.tbl",
+          "filters" -> "[(id + CAST(p AS long)) >= 0, (id + CAST(p AS long)) 
<= 4]"))
+      assert(spark.table(t).count() === 3)

Review Comment:
   Nit: this test repeats "DeleteFrom: - delete with invalid predicate" above 
it except for the condition and `filters`, and this count can't fail once 
`checkError` has passed: `_LEGACY_ERROR_TEMP_1110` is thrown at planning, from 
`canDeleteWhere`, which looks only at the filters (`DataSourceV2Strategy.scala` 
L570). Could the two share one test over `(condition, filters)` pairs, or could 
this one drop the INSERT and the count?
   



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala:
##########
@@ -486,6 +486,53 @@ 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))

Review Comment:
   Nit: this rebuilds `condition` from L493, L502 repeats the expected 
expression of L496, and `Rand(Literal(0L)) + a` is built three times at 
L509-517. `If(a > 0, condition, Literal(false))` and a shared `val` for the 
expected expression would show directly that the second case is the first one 
moved into a branch.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala:
##########
@@ -147,6 +147,21 @@ class CheckConstraintSuite extends QueryTest with 
CommandSuiteBase with DDLComma
     }
   }
 
+  test("SPARK-59962: Predicate of a check reading a computed value twice") {
+    // NULLIF reads `id + 1` twice. The predicate stored in the catalog is 
built from the check as
+    // the optimizer leaves it, so it must not refer to a column pre-evaluated 
under the command.

Review Comment:
   **The CHECK part of this fix sits one level too low: `toV2Constraint` builds 
the stored predicate from the optimized expression, which is what let 
`_common_expr_N` in, and this test pins that design.**
   
   `CheckConstraint.toV2Constraint` translates its current `child` 
(`constraints.scala` L131-132), and ALTER TABLE calls it on the optimized plan 
(`DataSourceV2Strategy.scala` L810-817). `DefaultValueExpression` and 
`GeneratedColumnExpression` avoid this as `AnalysisAwareExpression`s whose 
`toV2` uses `analyzedChild` (`ColumnDefinition.scala` L284, 
`GeneratedColumnExpression.scala` L135), for the reason the trait's doc gives: 
the optimizer folds context-dependent functions into definition-time literals 
(`AnalysisAwareExpression.scala` L21-25).
   
   That reason already bites here, before this PR. From reading the code, 
`ALTER TABLE t ADD CONSTRAINT valid_time CHECK (creation_time <= 
current_timestamp())` stores the ALTER time as a literal 
(`ComputeCurrentTime`), and since writes prefer `predicate()` 
(`ResolveTableConstraints.scala` L77), a later `INSERT INTO t VALUES (1, 
current_timestamp())` fails with CHECK_CONSTRAINT_VIOLATION, while the message 
shows `current_timestamp()`. `current_user()` is frozen the same way. The 
`current_*` tests at L410-645 use CREATE TABLE only, whose predicate 
`ResolveTableSpec` builds during analysis (L110), as peter-toth noted.
   
   Building from the analyzed expression would fix both, but it makes this 
constraint's predicate null, as it already is for CREATE TABLE, since 
`V2ExpressionBuilder` doesn't translate `NullIf`. Could this test assert the 
enforcement instead (my comment on L150), rather than `predicate() != null` and 
"as the optimizer leaves it", and the analyzed-expression change go to a 
separate JIRA?
   



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