This is an automated email from the ASF dual-hosted git repository.
LuciferYang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 6bfccc1e5b16 [SPARK-59962][SQL] Inline common expressions in a command
instead of pre-evaluating them under it
6bfccc1e5b16 is described below
commit 6bfccc1e5b166ceb026c1f93157418461fc98efb
Author: YangJie <[email protected]>
AuthorDate: Thu Oct 8 17:11:39 2026 +0800
[SPARK-59962][SQL] Inline common expressions in a command instead of
pre-evaluating them under it
### What changes were proposed in this pull request?
`RewriteWithExpression` pre-evaluates a common expression's definition that
is referenced more than once and is not cheap in a `Project` over the first
child whose output covers the definition's references. That assumes the
operator evaluates its expressions over its children's rows, and a command does
not, nor does a `Call`, whose arguments `InvokeProcedures` expects to find
foldable over its `ResolvedProcedure`. A CHECK constraint added by ALTER TABLE
is stored as a V2 predicate bui [...]
This adds a case for a `Command` or a `Call` to the rule that substitutes
every deterministic definition, and any definition read once, at its
references. A row-level write (`ReplaceData`, `WriteDelta`) gets it for its
`condition` only: its `groupFilterCondition` is read only by the runtime group
filter, which puts it in a filter over the table and optimizes it there, so a
`With` in it is treated as one in a conditional branch, inlined only where
memoizing gains nothing. Analysis reje [...]
`inlineDefsThatGainNothing` is split so the new case reuses its
substitution, and `canSubstitute` takes a flag that skips its cost check for it.
### Why are the changes needed?
With default settings, NULLIF or BETWEEN over a first operand that is not
cheap breaks these commands and CALL:
```sql
-- InMemoryTableCatalog as testcat
CREATE TABLE testcat.g (a INT, b INT GENERATED ALWAYS AS (nullif(a + 1,
3)));
-- [INTERNAL_ERROR] ... No plan for CreateTable ...
-- +- Project [a, b, (a + 1) AS _common_expr_0]
-- +- ResolvedIdentifier ..., g, [a, b]
-- CREATE OR REPLACE TABLE fails the same way
DELETE FROM testcat.t WHERE (a + b) BETWEEN 0 AND 4;
-- [INTERNAL_ERROR] Unexpected table relation: Project [a, b, (a + b) AS
_common_expr_1] ...
-- group-based row-level table partitioned by dep
DELETE FROM rowcat.p WHERE (id + pk) BETWEEN 2 AND 4;
-- [INTERNAL_ERROR_ATTRIBUTE_NOT_FOUND] Could not find _common_expr_4 in
[dep]
CALL cat.ns.sum(nullif((SELECT 5), 3), 1);
-- [INTERNAL_ERROR] Unexpected plan for optimized CALL statement
ALTER TABLE testcat.t ADD CONSTRAINT c1 CHECK (nullif(a + b, 3) > 0);
-- succeeds, but the V2 predicate stored in the catalog is
-- CASE WHEN _common_expr_1 = 3 THEN NULL ELSE _common_expr_1 > 0 END
-- and every later write fails with UNRESOLVED_COLUMN on _common_expr_1
```
### Does this PR introduce _any_ user-facing change?
Yes. The CREATE TABLE and CREATE OR REPLACE TABLE above succeed and INSERT
computes the generated column. The DELETE on `testcat.t` reports that the table
cannot delete by `[(a + b) >= 0, (a + b) <= 4]` instead of an internal error,
so a source that accepts those predicates can run it. The group-based DELETE
deletes the matching rows. The CALL returns 6. Writes to the table after the
ALTER TABLE enforce the check. A definition referenced twice in the expression
of any command but DECL [...]
### How was this patch tested?
- `RewriteWithExpressionSuite`: a DELETE condition holding a `With`,
directly and in a conditional branch, comes out inlined with no `Project` under
the command; a nondeterministic definition keeps its `With` when read twice and
is inlined when read once; and a `DECLARE VARIABLE` default keeps a `With` over
a definition that is deterministic but not cheap, which the new case would
inline.
- `DataSourceV2SQLSuiteV1Filter`: `DELETE ... WHERE (id + p) BETWEEN 0 AND
4` on a table that cannot delete by it fails with `Cannot delete from table ...
where [...]` listing both comparisons over the table's columns.
- `DeleteFromTableSuiteBase`: the same shape of condition deletes the
matching rows on the group-based and delta-based row-level tables, a
group-based delete copies only the rows of the groups the runtime group filter
keeps, and its condition has no `With` while its group filter condition keeps
one.
- `UpdateTableSuiteBase`: an UPDATE with that shape of condition updates
the matching row, its condition has no `With` and its group filter condition
keeps one, and on the group-based tables it copies no rows, as the runtime
group filter keeps only the group of that row. A condition that folds to true
leaves a true group filter condition and updates every row.
- `GeneratedColumnWriteSuite`: a table with `c INT GENERATED ALWAYS AS
(nullif(a + b, 3))` is created by CREATE TABLE and then replaced by CREATE OR
REPLACE TABLE, and INSERT computes `c` after each.
- `ProcedureSuite`: `CALL cat.ns.sum(nullif((SELECT 5), 3), 1)` returns 6.
- `CheckConstraintSuite`: after `ALTER TABLE ... ADD CONSTRAINT c1 CHECK
(nullif(id + 1, 3) > 0)`, an INSERT the check accepts succeeds and one it
rejects fails with CHECK_CONSTRAINT_VIOLATION; it passes with
`SPARK_ANSI_SQL_MODE=false` as well.
All of them fail without the change except the two delta-based DELETE runs,
which were not affected, and the `DECLARE VARIABLE` case, which passes on
master and fails if a `CreateVariable` reaches the new case. The UPDATE runs
fail only because master inlines their group filter condition; the UPDATE
itself works there.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5
Closes #59217 from LuciferYang/SPARK-59962.
Lead-authored-by: YangJie <[email protected]>
Co-authored-by: reviewer <reviewer@local>
Signed-off-by: yangjie01 <[email protected]>
---
.../catalyst/optimizer/RewriteWithExpression.scala | 96 +++++++++++++++++++---
.../optimizer/RewriteWithExpressionSuite.scala | 46 ++++++++++-
.../spark/sql/connector/DataSourceV2SQLSuite.scala | 15 ++++
.../sql/connector/DeleteFromTableSuiteBase.scala | 30 +++++++
.../sql/connector/GeneratedColumnWriteSuite.scala | 20 +++++
.../spark/sql/connector/ProcedureSuite.scala | 7 ++
.../spark/sql/connector/UpdateTableSuiteBase.scala | 35 ++++++++
.../command/v2/CheckConstraintSuite.scala | 23 ++++++
8 files changed, 260 insertions(+), 12 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala
index 722753c9958e..58bc78bc6e73 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpression.scala
@@ -22,7 +22,7 @@ import scala.collection.mutable
import org.apache.spark.SparkException
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.planning.PhysicalAggregation
-import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, CreateVariable,
LogicalPlan, PlanHelper, Project}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Call, Command,
CreateVariable, LogicalPlan, PlanHelper, Project, ReplaceData, WriteDelta}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreePattern.{COMMON_EXPR_REF,
CURRENT_LIKE, WITH_EXPRESSION}
import org.apache.spark.sql.internal.SQLConf
@@ -30,7 +30,11 @@ import org.apache.spark.util.Utils
/**
* Rewrites the `With` expressions by adding a `Project` to pre-evaluate the
common expressions, or
- * just inline them if they are cheap.
+ * just inline them if they are cheap. In a command other than
`CreateVariable`, whose expressions
+ * are mostly stored or translated rather than evaluated, and in a `Call`,
whose arguments must fold
+ * to literals, every deterministic definition is inlined whatever it costs,
and a nondeterministic
+ * one read more than once stays a `With`. The `groupFilterCondition` of a
row-level write keeps
+ * the `With`s worth memoizing for the runtime group filter.
*
* Since this rule can introduce new `Project` operators, it is advised to run
[[CollapseProject]]
* after this rule.
@@ -74,6 +78,36 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
// `LikeSimplification`, copies the `With` too, and each copy evaluates
the definition at most
// once each time it is evaluated.
case c: CreateVariable => c.mapExpressions(inlineWithsThatGainNothing)
+ // 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 mostly stored as metadata or turned
into source
+ // predicates, which cannot hold a memoized value, and 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. A `Call` evaluates its arguments, but
`InvokeProcedures`
+ // needs them to fold to literals over its `ResolvedProcedure`, which
neither a `With` nor a
+ // `Project` under it allows. So a deterministic definition is
substituted whatever it costs
+ // (see `canSubstitute`), and the copies compound with nesting, as with
+ // `spark.sql.alwaysInlineCommonExpr`. Each copy is evaluated wherever
the expression is: a
+ // CHECK predicate stored by ALTER TABLE at every later write, and a
`Call` argument when it
+ // is folded, so an impure foldable such as `aes_encrypt` over literals
is folded per copy.
+ // 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 matched above instead: its default is
evaluated and need not
+ // fold, so a definition there worth memoizing keeps its `With`.
+ case c @ (_: Command | _: Call) =>
+ c.mapExpressions(inlineCommandWiths)
case p if p.expressions.exists(_.containsPattern(WITH_EXPRESSION)) =>
applyInternal(p)
}
@@ -217,8 +251,9 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
}
/**
- * Whether substituting this definition into its references is as good as
evaluating it once: it
- * is referenced at most once anyway, or it is cheap to evaluate twice and
deterministic.
+ * Whether this definition may be substituted into its references: it is
referenced at most once
+ * anyway, or it is deterministic and, under `requireCheap`, cheap to
evaluate twice. With
+ * `requireCheap`, substituting is as good as evaluating it once.
*
* `CollapseProject.isCheap` answers what one evaluation costs, not whether
a second is allowed --
* it admits a `PythonUDF`, which may be nondeterministic and is still in
the tree here, since
@@ -229,12 +264,23 @@ 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 = {
+ if (requireCheap) {
+ !multiplyReferenced.contains(id) || (CollapseProject.isCheap(child) &&
child.deterministic)
+ } else {
+ // Determinism first, so a caller passing the count lazily computes it
only once it meets a
+ // nondeterministic definition.
+ child.deterministic || !multiplyReferenced.contains(id)
+ }
}
/**
@@ -266,6 +312,31 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
case w: With => inlineDefsThatGainNothing(w)
}
+ /**
+ * The `condition` and `groupFilterCondition` of a row-level write rewritten
as `apply` explains,
+ * or `None` if neither changes.
+ */
+ private def rowLevelConditions(
+ condition: Expression,
+ groupFilter: Option[Expression]): Option[(Expression,
Option[Expression])] = {
+ val newCondition = inlineCommandWiths(condition)
+ val newGroupFilter = groupFilter.map(inlineWithsThatGainNothing)
+ val unchanged = (newCondition eq condition) &&
+ newGroupFilter.zip(groupFilter).forall { case (n, o) => n eq o }
+ if (unchanged) None else Some((newCondition, newGroupFilter))
+ }
+
+ /**
+ * `e` with every `With` substituted as a command's expressions need,
bottom-up for nested ones:
+ * each deterministic definition whatever it costs, and any read at most
once.
+ */
+ private def inlineCommandWiths(e: Expression): Expression =
+ e.transformUpWithPruning(_.containsPattern(WITH_EXPRESSION)) {
+ case w: With =>
+ lazy val multiplyReferenced = multiplyReferencedIds(w.child, w.defs)
+ inlineDefs(w)(d => canSubstitute(d.child, d.id, multiplyReferenced,
requireCheap = false))
+ }
+
/**
* `w` with every definition that gains nothing from being memoized inlined
into its references:
* one cheap enough to evaluate twice, and one referenced at most once
anyway. This is the test
@@ -280,13 +351,16 @@ object RewriteWithExpression extends Rule[LogicalPlan] {
*/
private def inlineDefsThatGainNothing(w: With): Expression = {
val multiplyReferenced = multiplyReferencedIds(w.child, w.defs)
- val (toInline, toKeep) = w.defs.partition { d =>
- canSubstitute(d.child, d.id, multiplyReferenced)
- }
- if (toInline.isEmpty) {
+ inlineDefs(w)(d => canSubstitute(d.child, d.id, multiplyReferenced))
+ }
+
+ /** `w` with the definitions `toInline` selects substituted at their
references. */
+ private def inlineDefs(w: With)(toInline: CommonExpressionDef => Boolean):
Expression = {
+ val (inlined, toKeep) = w.defs.partition(toInline)
+ if (inlined.isEmpty) {
w
} else {
- val refToExpr = toInline.map(d => d.id -> d.child).toMap
+ val refToExpr = inlined.map(d => d.id -> d.child).toMap
val newChild =
w.child.transformWithPruning(_.containsPattern(COMMON_EXPR_REF)) {
// A ref of a definition kept here, or of an enclosing `With`, is left
for its owner.
case ref: CommonExpressionRef if refToExpr.contains(ref.id) =>
refToExpr(ref.id)
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala
index 81495236b586..8aaf15f0cd3a 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteWithExpressionSuite.scala
@@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.dsl.expressions._
import org.apache.spark.sql.catalyst.dsl.plans._
import org.apache.spark.sql.catalyst.expressions._
import org.apache.spark.sql.catalyst.plans.PlanTest
-import org.apache.spark.sql.catalyst.plans.logical.{CreateVariable,
DefaultValueExpression, LocalRelation, LogicalPlan, Project}
+import org.apache.spark.sql.catalyst.plans.logical.{CreateVariable,
DefaultValueExpression, DeleteFromTable, LocalRelation, LogicalPlan, Project}
import org.apache.spark.sql.catalyst.rules.RuleExecutor
import org.apache.spark.sql.catalyst.util.DateTimeUtils
import org.apache.spark.sql.connector.catalog.{DefaultCatalogManager,
Identifier}
@@ -486,6 +486,50 @@ 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 }
+ val inlined = (a + a) < 10 && (a + a) > 0
+ comparePlans(
+ Optimizer.execute(DeleteFromTable(testRelation, condition)),
+ DeleteFromTable(testRelation, inlined))
+
+ // Nor is one in a conditional branch kept: a `With` translates to no
source predicate.
+ comparePlans(
+ Optimizer.execute(DeleteFromTable(testRelation, If(a > 0, condition,
Literal(false)))),
+ DeleteFromTable(testRelation, If(a > 0, inlined, Literal(false))))
+ }
+
+ test("SPARK-59962: a nondeterministic definition in a command keeps its With
if read twice") {
+ // 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 definition = Rand(Literal(0L)) + a
+ val delete = DeleteFromTable(testRelation, With(definition) { case
Seq(ref) =>
+ ref < 10 && ref > 0
+ })
+ assert(Optimizer.execute(delete) == delete)
+
+ // Read once, it is inlined as anywhere else.
+ assert(Optimizer.execute(DeleteFromTable(testRelation,
+ With(definition) { case Seq(ref) => ref < 10 })) ==
+ DeleteFromTable(testRelation, definition < 10))
+ }
+
+ 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
+ // stays in its `With` rather than being substituted at each of its
references as a command's.
+ 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)
+ comparePlans(Optimizer.execute(declare), declare)
+ }
+
test("SPARK-59954: a With in a DECLARE VARIABLE default leaves the variable
names alone") {
val ident = ResolvedIdentifier(FakeSystemCatalog,
Identifier.of(Array("session"), "v"))
def declare(default: Expression): CreateVariable =
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
index bd2eb571da70..e46c68d19d0b 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
@@ -2683,6 +2683,21 @@ 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)")
+ 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]"))
+ }
+ }
+
test("UPDATE TABLE") {
val t = "testcat.ns1.ns2.tbl"
withTable(t) {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
index f8cc82eb8a89..22ac338fbd6e 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DeleteFromTableSuiteBase.scala
@@ -22,6 +22,7 @@ import org.apache.spark.sql.{AnalysisException, Row}
import org.apache.spark.sql.QueryTest.withQueryExecutionsCaptured
import org.apache.spark.sql.catalyst.expressions.CheckInvariant
import org.apache.spark.sql.catalyst.plans.logical.Filter
+import org.apache.spark.sql.catalyst.trees.TreePattern.WITH_EXPRESSION
import org.apache.spark.sql.connector.catalog.{Aborted, Committed,
InMemoryRowLevelOperationTable, InMemoryTable, InMemoryTruncatableOnlyTable,
InMemoryTruncatableOnlyTableCatalog, TableWritePrivilege}
import org.apache.spark.sql.connector.write.DeleteSummary
import org.apache.spark.sql.execution.datasources.v2.{DeleteFromTableExec,
ReplaceDataExec, TruncateTableExec, WriteDeltaExec}
@@ -259,6 +260,35 @@ 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. The untouched `finance` group is copied only if that filter
is missing.
+ 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" }
+ |{ "pk": 4, "id": 10, "dep": "finance" }
+ |""".stripMargin)
+
+ val (cond, groupFilterCond) = executeAndKeepConditions {
+ sql(s"DELETE FROM $tableNameAsString WHERE (id + pk) BETWEEN 2 AND 4")
+ }
+ // The condition reaches the source inlined. A group-based write also has
a group filter
+ // condition, only evaluated in the runtime group filter, which keeps the
`With` for that
+ // filter to memoize.
+ assert(!cond.containsPattern(WITH_EXPRESSION), s"condition: $cond")
+ assert(groupFilterCond.isDefined == !deltaDelete, s"group filter
condition: $groupFilterCond")
+ assert(groupFilterCond.forall(_.containsPattern(WITH_EXPRESSION)),
+ s"group filter condition: $groupFilterCond")
+
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString"),
+ Row(3, 3, "hr") :: Row(4, 10, "finance") :: Nil)
+
+ checkDeleteMetrics(numDeletedRows = 2, numCopiedRows = 1)
+ }
+
test("delete with aliases") {
createAndInitTable("pk INT NOT NULL, id INT, dep STRING",
"""{ "pk": 1, "id": 1, "dep": "hr" }
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GeneratedColumnWriteSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GeneratedColumnWriteSuite.scala
index c963d91005f1..943d883799e1 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/GeneratedColumnWriteSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/GeneratedColumnWriteSuite.scala
@@ -456,6 +456,26 @@ class GeneratedColumnWriteSuite extends QueryTest with
DatasourceV2SQLBase {
}
}
+ test("SPARK-59962: CREATE and REPLACE TABLE with a generation expression
reading a value twice") {
+ // NULLIF reads `a + b` twice. The table is created from the expression
rather than evaluating
+ // it, so nothing may be pre-evaluated under the CREATE or REPLACE TABLE.
The second statement
+ // replaces the table the first one created.
+ val tblName = "my_tab"
+ withTable(s"testcat.$tblName") {
+ Seq("CREATE TABLE", "CREATE OR REPLACE TABLE").foreach { create =>
+ sql(s"""$create testcat.$tblName(
+ | a INT,
+ | b INT,
+ | c INT GENERATED ALWAYS AS (nullif(a + b, 3))
+ |) USING foo""".stripMargin)
+ sql(s"INSERT INTO testcat.$tblName(a, b) VALUES (1, 1), (1, 2)")
+ checkAnswer(
+ spark.table(s"testcat.$tblName"),
+ Seq(Row(1, 1, 2), Row(1, 2, null)))
+ }
+ }
+ }
+
test("allowNullableIngest config controls missing non-generated columns") {
val tblName = "my_tab"
withTable(s"testcat.$tblName") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/ProcedureSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/ProcedureSuite.scala
index cf45f4f416dc..76bed5649ffd 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/ProcedureSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/ProcedureSuite.scala
@@ -98,6 +98,13 @@ class ProcedureSuite extends SharedSparkSession with
BeforeAndAfter {
checkAnswer(sql("CALL cat.ns.sum((1 + 1) * 2, in2 => (2 + 1) / 3)"),
Row(5) :: Nil)
}
+ test("SPARK-59962: an argument reading a value twice") {
+ // NULLIF reads the scalar subquery twice. Pre-evaluating it in a
`Project` over the
+ // `ResolvedProcedure` would leave a plan `InvokeProcedures` does not
expect.
+ catalog.createProcedure(Identifier.of(Array("ns"), "sum"), UnboundSum)
+ checkAnswer(sql("CALL cat.ns.sum(nullif((SELECT 5), 3), 1)"), Row(6) ::
Nil)
+ }
+
test("type coercion") {
catalog.createProcedure(Identifier.of(Array("ns"), "sum"), UnboundLongSum)
checkAnswer(sql("CALL cat.ns.sum(1, 2)"), Row(3) :: Nil)
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
index 3269081227e5..c0506ca03dcc 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/UpdateTableSuiteBase.scala
@@ -21,7 +21,9 @@ import org.apache.spark.SparkRuntimeException
import org.apache.spark.internal.config
import org.apache.spark.sql.{sources, AnalysisException, Row}
import org.apache.spark.sql.QueryTest.withQueryExecutionsCaptured
+import org.apache.spark.sql.catalyst.expressions.Literal.TrueLiteral
import org.apache.spark.sql.catalyst.plans.logical.{ReplaceData, WriteDelta}
+import org.apache.spark.sql.catalyst.trees.TreePattern.WITH_EXPRESSION
import org.apache.spark.sql.connector.catalog.{Aborted, Column,
ColumnDefaultValue, Committed, InMemoryTable, TableChange, TableInfo}
import org.apache.spark.sql.connector.expressions.{GeneralScalarExpression,
LiteralValue}
import org.apache.spark.sql.connector.write.UpdateSummary
@@ -179,6 +181,39 @@ 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; with the filter, a group-based update copies none of the `hr`
rows. 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)
+ checkUpdateMetrics(numUpdatedRows = 1, numCopiedRows = 0)
+
+ val (_, constantGroupFilterCond) = executeAndKeepConditions {
+ sql(s"UPDATE $tableNameAsString SET dep = 'y' WHERE 1 BETWEEN 0 AND 2")
+ }
+ assert(constantGroupFilterCond.contains(TrueLiteral),
+ s"group filter condition: $constantGroupFilterCond")
+ checkAnswer(
+ sql(s"SELECT * FROM $tableNameAsString"),
+ Row(1, 100, "y") :: Row(2, 200, "y") :: Row(3, 300, "y") :: Nil)
+ }
+
test("update with aliases") {
createAndInitTable("pk INT NOT NULL, salary INT, dep STRING",
"""{ "pk": 1, "salary": 100, "dep": "hr" }
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala
index 8e295ecc3d5d..631c7bfb37c9 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v2/CheckConstraintSuite.scala
@@ -147,6 +147,29 @@ class CheckConstraintSuite extends QueryTest with
CommandSuiteBase with DDLComma
}
}
+ test("SPARK-59962: Check added by ALTER TABLE reading a computed value
twice") {
+ // NULLIF reads `id + 1` twice. A predicate stored with a column
pre-evaluated under the ALTER
+ // TABLE would make every later write fail to resolve it.
+ 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)")
+ // `nullif(3, 3)` is NULL, which a check accepts.
+ sql(s"INSERT INTO $t VALUES (2, 'a')")
+ val error = intercept[SparkRuntimeException] {
+ sql(s"INSERT INTO $t VALUES (-5, 'b')")
+ }
+ checkError(
+ exception = error,
+ condition = "CHECK_CONSTRAINT_VIOLATION",
+ sqlState = "23001",
+ parameters = Map(
+ "constraintName" -> "c1",
+ "expression" -> "nullif(id + 1, 3) > 0",
+ "values" -> " - id : -5"))
+ checkAnswer(sql(s"SELECT * FROM $t"), Row(2, "a"))
+ }
+ }
+
def getConstraintCharacteristics(): Seq[(String, String)] = {
Seq(
("", s"ENFORCED NORELY"),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]