This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 6a1b28bcb8f8 [SPARK-60569][SQL] Push down filters on scalar subqueries 
merged by `MergeSubplans`
6a1b28bcb8f8 is described below

commit 6a1b28bcb8f862515e2c4044e824ecfb98632f14
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Oct 27 10:57:50 2026 -0700

    [SPARK-60569][SQL] Push down filters on scalar subqueries merged by 
`MergeSubplans`
    
    ### What changes were proposed in this pull request?
    
    `MergeSubplans` merges different non-correlated scalar subqueries into one, 
and rewrites each reference to `GetStructField(ScalarSubquery(<merged>), i)`. 
At run time, the filter pushdown paths replaced only the `ScalarSubquery` node 
with its result. That left `GetStructField(Literal(<struct>), i)`, while the 
filter translators need a bare literal. This PR replaces such a field read with 
a literal of its own.
    
    - Add `replaceWithLiterals` to a new companion object of the physical 
`ScalarSubquery` (`org.apache.spark.sql.execution`). It replaces each scalar 
subquery in an expression with a literal of its result, and a `GetStructField` 
over a scalar subquery with a literal of that field. The field is read straight 
from the subquery result, so no literal of the whole struct is built.
    - Use it where a scalar subquery is literalized for pushdown:
      - `FileSourceScanLike.pushedDownFilters`: the data filters of a V1 file 
scan (SPARK-43402).
      - `DataSourceV2Strategy.translateScalarSubqueryFilterV2`: the runtime 
filters of a `SupportsRuntimeV2Filtering` scan (SPARK-56467).
      - `PushDownUtils.unwrapRuntimeFilterExpression`: the runtime filters of a 
`SupportsRuntimeCatalystFiltering` scan (SPARK-58523), and the 
`PartitionPredicate`s derived from runtime filters.
    - `FileSourceScanLike.metadata`, which is only for display, maps a scalar 
subquery to the string literal `ScalarSubquery#<id>`, because the subquery has 
not run yet. It now maps a field read out of a merged subquery to that string 
as a whole, so `PushedFilters` shows the filter as it does for an unmerged 
subquery. `MergeSubplans` keeps the `exprId` of each original subquery, so the 
id is the same too. Folding the field read over the string would fail. A filter 
with an `IN` over scalar [...]
    
    ### Why are the changes needed?
    
    `MergeSubplans` runs whenever `spark.sql.execution.reuseSubquery` is true, 
which is the default. With Parquet tables `t1(c1 INT)` holding 1, 2, 3 and 
`t2(c2 INT)` holding 4, 5, 6, the two subqueries of
    
    ```sql
    SELECT * FROM t1 WHERE c1 > (SELECT min(c2) FROM t2) AND c1 < (SELECT 
max(c2) FROM t2)
    ```
    
    are merged, and both data filters are silently left out of the pushed 
filters. The scan of `t1` returns all 3 rows:
    
    ```
    PushedFilters: [IsNotNull(c1)]
    ```
    
    With this PR, both are pushed, `c1 > 4` skips the row group, and the scan 
returns no row:
    
    ```
    PushedFilters: [IsNotNull(c1), GreaterThan(c1,ScalarSubquery#116), 
LessThan(c1,ScalarSubquery#117)]
    ```
    
    Each filter in a query of its own was already pushed, and so were both with 
`spark.sql.execution.reuseSubquery=false`, which disables the merge. An `IN` 
over two such subqueries, like `c1 IN ((SELECT min(c2) FROM t2), (SELECT 
max(c2) FROM t2))`, was not pushed either.
    
    - V1 file sources could not skip data with these filters (for example, 
Parquet row groups and pages) since 
[v4.0.0](https://github.com/apache/spark/releases/tag/v4.0.0) (2025-05-19), 
which added the scalar subquery pushdown (SPARK-43402).
    - A `SupportsRuntimeV2Filtering` scan did not receive the predicate, so it 
could not prune partitions with it, since 
[v4.2.0](https://github.com/apache/spark/releases/tag/v4.2.0) (2026-07-11), 
which routed scalar subquery filters to it (SPARK-56467). `V2ExpressionBuilder` 
folds only `contextIndependentFoldable` expressions, and `GetStructField` is 
not one.
    - A `SupportsRuntimeCatalystFiltering` scan, which is not released yet, 
received a filter like `part = [0,3,1].max(val)`, which carries the whole 
struct, instead of `part = 3`.
    
    Query results were correct, because the `Filter` above the scan keeps these 
filters.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No change to query results. Data sources now receive filters on scalar 
subqueries that `MergeSubplans` merged, as they already did for an unmerged 
subquery, and can skip data with them. EXPLAIN lists them in the 
`PushedFilters` of a V1 file scan, except a filter with an `IN`, which is left 
out for an unmerged subquery too (SPARK-60577).
    
    ### How was this patch tested?
    
    Added three tests and extended one. Each fails without the main change:
    
    - `SubquerySuite`, "SPARK-60569: FileSourceScanExec pushes down data 
filters with merged scalar subqueries": the SPARK-43402 test with two 
subqueries that `MergeSubplans` merges. It checks `PushedFilters`, and through 
the scan's `numOutputRows` that the pushed `c1 > 4` and `c1 < 1` each skip the 
row group while `c1 > -6 AND c1 < 6` keeps it. Without the change, 
`PushedFilters` is `[IsNotNull(c1)]`, and `numOutputRows` is 3 instead of 0.
    - `SubquerySuite`, "SPARK-60577: FileSourceScanExec supports push down IN 
data filter with scalar subquery": it now also runs the `IN` over two 
subqueries under the default configuration, where `MergeSubplans` merges them, 
on `DATE`, `TIMESTAMP` and `DECIMAL` columns. Without the change, the `IN` is 
not pushed, and `numOutputRows` is 3 instead of 0.
    - `DataSourceV2SQLSuiteV2Filter`, "SPARK-60569: scalar subquery filters 
merged by MergeSubplans are pushed to the scan": a `SupportsRuntimeV2Filtering` 
scan keeps 1 of 10 partitions. Without the change, it keeps all 10.
    - `DataSourceV2CatalystRuntimeFilterSuite`, "SPARK-60569: field read out of 
a merged scalar subquery -> literalized as a whole": a 
`SupportsRuntimeCatalystFiltering` scan receives `part = 3`. Without the 
change, it receives `part = [0,3,1].max(val)`.
    
    These also pass locally: `SubquerySuite`, `DataSourceV2SQLSuiteV2Filter`, 
`DataSourceV2CatalystRuntimeFilterSuite`, 
`DataSourceV2EnhancedRuntimePartitionFilterSuite`, 
`DeltaBasedRowLevelOperationCatalystRuntimeFilterSuite`, 
`GroupBasedRowLevelOperationCatalystRuntimeFilterSuite`, 
`StorageFilterPushDownSuite`, `ParquetStorageFilterSuite`, the suites under 
`execution/planmerging`, `ExplainSuite`, `ExplainSuiteAE`, the TPC-DS and TPC-H 
plan stability suites (no approved plan changes), an [...]
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #59810 from dongjoon-hyun/SPARK-60569.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit 882f8637adeee005dd12a4680d3c6bd0acab5d35)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../spark/sql/execution/DataSourceScanExec.scala   | 23 +++++----
 .../datasources/v2/DataSourceV2Strategy.scala      |  5 +-
 .../execution/datasources/v2/PushDownUtils.scala   |  2 +-
 .../org/apache/spark/sql/execution/subquery.scala  | 16 +++++-
 .../scala/org/apache/spark/sql/SubquerySuite.scala | 58 +++++++++++++++++++---
 .../DataSourceV2CatalystRuntimeFilterSuite.scala   | 26 ++++++++++
 .../spark/sql/connector/DataSourceV2SQLSuite.scala | 31 ++++++++++++
 7 files changed, 140 insertions(+), 21 deletions(-)

diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
index 77f058f6269b..d26f8190f438 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala
@@ -515,12 +515,10 @@ trait FileSourceScanLike extends DataSourceScanExec with 
SessionStateHelper {
 
   private def translateToV1Filters(
       dataFilters: Seq[Expression],
-      scalarSubqueryToLiteral: execution.ScalarSubquery => Literal): 
Seq[Filter] = {
-    val scalarSubqueryReplaced = dataFilters.map(_.transform {
-      // Replace scalar subquery to literal so that 
`DataSourceStrategy.translateFilter` can
-      // support translating it.
-      case scalarSubquery: execution.ScalarSubquery => 
scalarSubqueryToLiteral(scalarSubquery)
-    })
+      replaceScalarSubqueries: Expression => Expression): Seq[Filter] = {
+    // Replace scalar subquery to literal so that 
`DataSourceStrategy.translateFilter` can
+    // support translating it.
+    val scalarSubqueryReplaced = dataFilters.map(replaceScalarSubqueries)
 
     val supportNestedPredicatePushdown = 
DataSourceUtils.supportNestedPredicatePushdown(relation)
     // `dataFilters` should not include any constant metadata col filters
@@ -535,7 +533,8 @@ trait FileSourceScanLike extends DataSourceScanExec with 
SessionStateHelper {
 
   // This field may execute subquery expressions and should not be accessed 
during planning.
   @transient
-  protected lazy val pushedDownFilters: Seq[Filter] = 
translateToV1Filters(dataFilters, _.toLiteral)
+  protected lazy val pushedDownFilters: Seq[Filter] =
+    translateToV1Filters(dataFilters, 
execution.ScalarSubquery.replaceWithLiterals)
 
   override lazy val metadata: Map[String, String] = {
     def seqToString(seq: Seq[Any]) = seq.mkString("[", ", ", "]")
@@ -546,13 +545,19 @@ trait FileSourceScanLike extends DataSourceScanExec with 
SessionStateHelper {
     // `metadata` is accessed during planning and the scalar subquery is not 
executed yet. Here
     // we get the pretty string of the scalar subquery, for display purpose 
only. A filter with an
     // `In` that has a scalar subquery in its list is not displayed, since the 
values of an `In`
-    // are converted to the type of its column, which the string does not have.
+    // are converted to the type of its column, which the string does not 
have. A field read out
+    // of a subquery that `MergeSubplans` merged with others gets the string 
as a whole, since the
+    // string has no field to read.
     val pushedFiltersForDisplay = translateToV1Filters(
       dataFilters.filterNot(_.exists {
         case in: In => 
in.list.exists(_.exists(_.isInstanceOf[execution.ScalarSubquery]))
         case _ => false
       }),
-      s => Literal("ScalarSubquery#" + s.exprId.id))
+      _.transform {
+        case GetStructField(s: execution.ScalarSubquery, _, _) =>
+          Literal("ScalarSubquery#" + s.exprId.id)
+        case s: execution.ScalarSubquery => Literal("ScalarSubquery#" + 
s.exprId.id)
+      })
     val metadata =
       Map(
         "Format" -> relation.fileFormat.toString,
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala
index 32232532bbd6..d0c331282473 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2Strategy.scala
@@ -1019,10 +1019,7 @@ private[sql] object DataSourceV2Strategy extends Logging 
{
    */
   protected[sql] def translateScalarSubqueryFilterV2(
       expr: Expression): Option[Predicate] = {
-    val literalized = expr.transform {
-      case s: ExecScalarSubquery => s.toLiteral
-    }
-    translateFilterV2(literalized)
+    translateFilterV2(ExecScalarSubquery.replaceWithLiterals(expr))
   }
 
   /**
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
index ea4cac0d221e..6d2417c3e77e 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/PushDownUtils.scala
@@ -506,7 +506,7 @@ object PushDownUtils extends Logging {
         None
       case DynamicPruningExpression(e) => Some(e)
       case _: DynamicPruning => None
-      case f => Some(f.transform { case s: ExecScalarSubquery => s.toLiteral })
+      case f => Some(ExecScalarSubquery.replaceWithLiterals(f))
     }
 
   /**
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala
index ea7f5126a78c..121e9ca14fdc 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/subquery.scala
@@ -22,7 +22,7 @@ import scala.collection.immutable.TreeSet
 import org.apache.spark.QueryContext
 import org.apache.spark.broadcast.Broadcast
 import org.apache.spark.sql.catalyst.{expressions, InternalRow}
-import org.apache.spark.sql.catalyst.expressions.{CreateNamedStruct, 
Expression, ExprId, InSet, InterpretedOrdering, ListQuery, Literal, 
PlanExpression, Predicate, SupportQueryContext}
+import org.apache.spark.sql.catalyst.expressions.{CreateNamedStruct, 
Expression, ExprId, GetStructField, InSet, InterpretedOrdering, ListQuery, 
Literal, PlanExpression, Predicate, SupportQueryContext}
 import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
ExprCode}
 import org.apache.spark.sql.catalyst.expressions.codegen.Block._
 import org.apache.spark.sql.catalyst.rules.Rule
@@ -115,6 +115,20 @@ case class ScalarSubquery(
   }
 }
 
+object ScalarSubquery {
+  /**
+   * `expr` with each scalar subquery replaced by a literal of its result, so 
that a filter
+   * translation, which needs a bare literal, can match it. `MergeSubplans` 
returns the results of
+   * the subqueries it merges in one struct, and reads each result out of it 
with a
+   * [[GetStructField]], so such a field read becomes a literal of its own. 
The field is read
+   * straight from the result: a literal of the whole struct would validate 
every field of it.
+   */
+  def replaceWithLiterals(expr: Expression): Expression = expr.transform {
+    case g @ GetStructField(_: ScalarSubquery, _, _) => Literal(g.eval(), 
g.dataType)
+    case s: ScalarSubquery => s.toLiteral
+  }
+}
+
 /**
  * The physical node of in-subquery. When this is used for Dynamic Partition 
Pruning, as the pruning
  * happens at the driver side, we don't broadcast subquery result.
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
index bd2bf1caa9c2..a34862b3052b 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/SubquerySuite.scala
@@ -23,8 +23,8 @@ import org.apache.spark.{SparkArithmeticException, SparkConf, 
SparkRuntimeExcept
 import org.apache.spark.serializer.JavaSerializer
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{CreateNamedStruct, EqualTo, 
Expression,
-  ExprId, GreaterThanOrEqual, Literal, NamedExpression, OuterReference, 
PlanExpression, Rand,
-  SubqueryExpression}
+  ExprId, GetStructField, GreaterThanOrEqual, Literal, NamedExpression, 
OuterReference,
+  PlanExpression, Rand, SubqueryExpression}
 import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext, 
GeneratePredicate}
 import org.apache.spark.sql.catalyst.plans.{LeftAnti, LeftSemi}
 import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
LogicalPlan, Project, Sort, Union}
@@ -2549,11 +2549,12 @@ class SubquerySuite extends SharedSparkSession
           "dec" -> "CAST(5 AS DECIMAL(10, 2))").foreach { case (column, 
literal) =>
           checkFileSourceScan(
             s"SELECT * FROM t1 WHERE $column IN ($literal, (SELECT 
max($column) FROM t2))", 1)
-          // Otherwise, the two scalar subqueries are merged into one.
+          val twoSubqueries = s"SELECT * FROM t1 WHERE $column IN " +
+            s"((SELECT min($column) FROM t2), (SELECT max($column) FROM t2))"
+          // `MergeSubplans` merges the two subqueries into one, unless 
subquery reuse is off.
+          checkFileSourceScan(twoSubqueries, 2)
           withSQLConf(SQLConf.SUBQUERY_REUSE_ENABLED.key -> "false") {
-            checkFileSourceScan(
-              s"SELECT * FROM t1 WHERE $column IN " +
-                s"((SELECT min($column) FROM t2), (SELECT max($column) FROM 
t2))", 2)
+            checkFileSourceScan(twoSubqueries, 2)
           }
         }
 
@@ -2570,6 +2571,51 @@ class SubquerySuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-60569: FileSourceScanExec pushes down data filters with merged 
scalar subqueries") {
+    def checkFileSourceScan(query: String, answer: Seq[Row]): Unit = {
+      val df = sql(query)
+      checkAnswer(df, answer)
+      val fileSourceScanExec = collect(df.queryExecution.executedPlan) {
+        case f: FileSourceScanExec => f
+      }
+      sparkContext.listenerBus.waitUntilEmpty()
+      assert(fileSourceScanExec.size === 1)
+      // `MergeSubplans` merged the two subqueries into one, and each filter 
reads its result out
+      // of the struct the merged one returns.
+      val mergedSubqueryResults = 
fileSourceScanExec.head.dataFilters.flatMap(_.collect {
+        case g @ GetStructField(_: ScalarSubquery, _, _) => g
+      })
+      assert(mergedSubqueryResults.length === 2)
+      val pushedFilters = fileSourceScanExec.head.metadata("PushedFilters")
+      assert(pushedFilters.contains("GreaterThan(c1,ScalarSubquery#"))
+      assert(pushedFilters.contains("LessThan(c1,ScalarSubquery#"))
+      assert(fileSourceScanExec.head.metrics("numFiles").value === 1)
+      assert(fileSourceScanExec.head.metrics("numOutputRows").value === 
answer.size)
+    }
+
+    withTable("t1", "t2") {
+      withSQLConf(SQLConf.LEAF_NODE_DEFAULT_PARALLELISM.key -> "1") {
+        Seq(1, 2, 3).toDF("c1").write.format("parquet").saveAsTable("t1")
+        Seq(4, 5, 6).toDF("c2").write.format("parquet").saveAsTable("t2")
+
+        // The row group is skipped by `c1 > 4`.
+        checkFileSourceScan(
+          "SELECT * FROM t1 WHERE c1 > (SELECT min(c2) FROM t2) AND c1 < 
(SELECT max(c2) FROM t2)",
+          Seq.empty)
+        // The row group is skipped by `c1 < 1`.
+        checkFileSourceScan(
+          "SELECT * FROM t1 " +
+            "WHERE c1 > (SELECT min(c2) - 10 FROM t2) AND c1 < (SELECT max(c2) 
- 5 FROM t2)",
+          Seq.empty)
+        // The row group is kept by `c1 > -6` and `c1 < 6`, but not if the 
fields were swapped.
+        checkFileSourceScan(
+          "SELECT * FROM t1 " +
+            "WHERE c1 > (SELECT min(c2) - 10 FROM t2) AND c1 < (SELECT max(c2) 
FROM t2)",
+          Row(1) :: Row(2) :: Row(3) :: Nil)
+      }
+    }
+  }
+
   test("SPARK-44562: Add OptimizeOneRowRelationSubquery in batch of Subquery") 
{
     withTempView("v1", "v2") {
       sql(
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
index 520d05e5c48b..863ce6e1af26 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2CatalystRuntimeFilterSuite.scala
@@ -349,6 +349,32 @@ class DataSourceV2CatalystRuntimeFilterSuite extends 
SharedSparkSession {
     }
   }
 
+  test("SPARK-60569: field read out of a merged scalar subquery -> literalized 
as a whole") {
+    val tbl = s"$catalogName.tbl_merged"
+    val dim = s"$catalogName.dim_merged"
+    withTable(tbl, dim) {
+      sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Source PARTITIONED 
BY (part)")
+      for (i <- 0 until 5) {
+        sql(s"INSERT INTO $tbl VALUES ($i, $i)")
+      }
+      sql(s"CREATE TABLE $dim (val INT) USING $v2Source")
+      sql(s"INSERT INTO $dim VALUES (1), (3)")
+
+      // `MergeSubplans` merges the two subqueries into one, and each filter 
reads its result out
+      // of the struct the merged one returns. The scan gets that result, not 
the whole struct.
+      val df = sql(s"SELECT * FROM $tbl " +
+        s"WHERE part = (SELECT max(val) FROM $dim) AND id > (SELECT min(val) 
FROM $dim)")
+      checkAnswer(df, Row(3, 3))
+
+      assertScalarSubqueryRuntimeFilters(df)
+      val runtimeFilter = collectBatchScan(df).runtimeFilters.head
+      assert(runtimeFilter.exists(_.isInstanceOf[GetStructField]),
+        s"Expected a runtime filter on a merged scalar subquery, got 
$runtimeFilter")
+      val part = AttributeReference("part", IntegerType, nullable = false)()
+      assertPushedCatalystPredicatesEqual(df, EqualTo(part, Literal(3)))
+    }
+  }
+
   test("filter with no V2 translation -> pushed instead of dropped") {
     val tbl = s"$catalogName.tbl_untranslatable"
     val dim = s"$catalogName.dim_untranslatable"
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 e46c68d19d0b..24c4efbfb41d 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
@@ -5624,6 +5624,37 @@ class DataSourceV2SQLSuiteV2Filter extends 
DataSourceV2SQLSuite {
         s"Expected the non-deterministic filter above the scan, got 
$postScanConditions")
     }
   }
+
+  test("SPARK-60569: scalar subquery filters merged by MergeSubplans are 
pushed to the scan") {
+    val tbl = s"${catalogAndNamespace}tbl"
+    val dim = s"${catalogAndNamespace}dim"
+    withTable(tbl, dim) {
+      sql(s"CREATE TABLE $tbl (id INT, part INT) USING $v2Format PARTITIONED 
BY (part)")
+      for (i <- 0 until 10) {
+        sql(s"INSERT INTO $tbl VALUES ($i, $i)")
+      }
+
+      sql(s"CREATE TABLE $dim (val INT) USING $v2Format")
+      sql(s"INSERT INTO $dim VALUES (1), (3)")
+
+      // `MergeSubplans` merges the two subqueries into one, and each filter 
reads its result out
+      // of the struct the merged one returns.
+      val df = sql(s"SELECT * FROM $tbl " +
+        s"WHERE part = (SELECT max(val) FROM $dim) AND id > (SELECT min(val) 
FROM $dim)")
+      checkAnswer(df, Row(3, 3))
+
+      val batchScan = collect(df.queryExecution.executedPlan) {
+        case b: BatchScanExec => b
+      }.head
+      
assert(batchScan.runtimeFilters.exists(_.exists(_.isInstanceOf[GetStructField])),
+        s"Expected a runtime filter on a merged scalar subquery, got 
${batchScan.runtimeFilters}")
+
+      // Verify partition pruning: only 1 of 10 partitions should remain
+      val numPartitions = batchScan.filteredPartitions.count(_.isDefined)
+      assert(numPartitions == 1,
+        s"Expected 1 partition after scalar subquery pruning, got 
$numPartitions")
+    }
+  }
 }
 
 class ReserveSchemaNullabilityCatalog extends InMemoryCatalog {


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

Reply via email to