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

gengliangwang 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 4c9713ee755a [SPARK-60558][SQL] Expose subquery planning context to 
DataSource V2 scan builders
4c9713ee755a is described below

commit 4c9713ee755a7c814693e4f53843b513dfdd4103
Author: yyanyy <[email protected]>
AuthorDate: Mon Oct 26 19:37:40 2026 -0700

    [SPARK-60558][SQL] Expose subquery planning context to DataSource V2 scan 
builders
    
    ### What changes were proposed in this pull request?
    
    Add the evolving `ScanBuilderInfo` API with an `isInSubquery()` flag and a 
default `SupportsRead.newScanBuilder(ScanBuilderInfo, 
CaseInsensitiveStringMap)` overload. Spark supplies this context when creating 
batch scan builders, including the destination context when rebuilding merged 
scans.
    
    The default overload delegates to the existing options-only method, 
preserving compatibility with existing connectors and passing the original read 
options unchanged. Each builder receives a fresh context object. The context is 
advisory and must not change the rows returned by a scan.
    
    ### Why are the changes needed?
    
    Different stages of query planning may call for different connector 
behavior. Providing subquery-planning context allows connectors to adjust their 
behavior and avoid unnecessary work. This also provides a foundation for Spark 
to expose additional planning context to connectors in the future.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Passed 199 tests across `V2ScanRelationPushDownSubquerySuite`, 
`MergeSubplansSuite`, and `DataSourceV2Suite`:
    
    ```text
    build/sbt 'sql/testOnly *V2ScanRelationPushDownSubquerySuite 
*MergeSubplansSuite org.apache.spark.sql.connector.DataSourceV2Suite'
    ```
    
    The new coverage checks context availability before pushdown and statistics 
selection, compatibility with options-only connectors, unchanged option-map 
identity and contents, fresh context across repeated table use, 
scalar/IN/EXISTS subqueries, local/global temporary-view reuse and replacement, 
and the destination context of rebuilt merged scans.
    
    Java Checkstyle, Scala style checks for SQL production and test sources, 
and `git diff --check` also passed.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Codex 0.163.0 (GPT-6)
    
    Closes #59662 from yyanyy/spark-subquery-stats-20261020.
    
    Authored-by: yyanyy <[email protected]>
    Signed-off-by: Gengliang Wang <[email protected]>
---
 .../spark/sql/connector/catalog/SupportsRead.java  |  18 ++
 .../spark/sql/connector/read/ScanContext.java      |  64 +++++
 .../datasources/v2/V2ScanRelationPushDown.scala    |  26 +-
 .../sql/execution/planmerging/PlanMerger.scala     |   5 +-
 .../v2/V2ScanRelationPushDownSubquerySuite.scala   | 303 +++++++++++++++++++++
 .../execution/planmerging/MergeSubplansSuite.scala |  36 ++-
 6 files changed, 442 insertions(+), 10 deletions(-)

diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/SupportsRead.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/SupportsRead.java
index e0ed9772dced..83684e4242ab 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/SupportsRead.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/catalog/SupportsRead.java
@@ -20,6 +20,7 @@ package org.apache.spark.sql.connector.catalog;
 import org.apache.spark.annotation.Evolving;
 import org.apache.spark.sql.connector.read.Scan;
 import org.apache.spark.sql.connector.read.ScanBuilder;
+import org.apache.spark.sql.connector.read.ScanContext;
 import org.apache.spark.sql.util.CaseInsensitiveStringMap;
 
 /**
@@ -40,4 +41,21 @@ public interface SupportsRead extends Table {
    *                string-to-string map.
    */
   ScanBuilder newScanBuilder(CaseInsensitiveStringMap options);
+
+  /**
+   * Returns a {@link ScanBuilder} configured with read options and planning 
context.
+   *
+   * <p>The default implementation delegates to
+   * {@link #newScanBuilder(CaseInsensitiveStringMap)}. Data sources can 
override this method to use
+   * the planning context during builder creation. Calls to the options-only 
overload do not provide
+   * subquery context.
+   *
+   * @param context the planning context for this scan
+   * @param options the options for reading, which is an immutable 
case-insensitive
+   *                string-to-string map
+   * @since 4.4.0
+   */
+  default ScanBuilder newScanBuilder(ScanContext context, 
CaseInsensitiveStringMap options) {
+    return newScanBuilder(options);
+  }
 }
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/ScanContext.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/ScanContext.java
new file mode 100644
index 000000000000..b9ae61263a90
--- /dev/null
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/ScanContext.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.connector.read;
+
+import org.apache.spark.annotation.Evolving;
+
+/**
+ * Planning context provided to a data source when creating a {@link 
ScanBuilder}.
+ *
+ * @since 4.4.0
+ */
+@Evolving
+public class ScanContext {
+  private final boolean isInSubquery;
+
+  public ScanContext(boolean isInSubquery) {
+    this.isInSubquery = isInSubquery;
+  }
+
+  /**
+   * Returns whether Spark is constructing this scan within a subquery.
+   *
+   * <p>The context describes the planning scope when the scan builder is 
created. Subsequent
+   * optimizer rewrites may move or reuse the scan in another scope.
+   *
+   * <p>Data sources can use this context to adjust planning work, such as 
statistics computation.
+   * It must not change the rows returned by the scan.
+   */
+  public boolean isInSubquery() {
+    return isInSubquery;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (!(o instanceof ScanContext that)) return false;
+    return isInSubquery == that.isInSubquery;
+  }
+
+  @Override
+  public int hashCode() {
+    return Boolean.hashCode(isInSubquery);
+  }
+
+  @Override
+  public String toString() {
+    return "ScanContext{isInSubquery=" + isInSubquery + "}";
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
index 768ed370c4cc..7140fc44adaa 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDown.scala
@@ -28,14 +28,14 @@ import 
org.apache.spark.sql.catalyst.expressions.{aggregate, Alias, And, Attribu
 import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
 import org.apache.spark.sql.catalyst.optimizer.{CollapseGroupedSumOfCount, 
CollapseProject}
 import org.apache.spark.sql.catalyst.planning.{PhysicalOperation, 
ScanOperation}
-import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
LeafNode, Limit, LimitAndOffset, LocalLimit, LocalRelation, LogicalPlan, 
Offset, OffsetAndLimit, Project, Sample, SampleMethod, Sort}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Filter, Join, 
LeafNode, Limit, LimitAndOffset, LocalLimit, LocalRelation, LogicalPlan, 
Offset, OffsetAndLimit, Project, Sample, SampleMethod, Sort, Subquery}
 import org.apache.spark.sql.catalyst.plans.logical.PlanHelper
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.types.DataTypeUtils.toAttributes
 import org.apache.spark.sql.connector.expressions.{SortOrder => V2SortOrder}
 import org.apache.spark.sql.connector.expressions.aggregate.{Aggregation, Avg, 
Count, CountStar, Max, Min, Sum}
 import org.apache.spark.sql.connector.expressions.filter.Predicate
-import org.apache.spark.sql.connector.read.{Scan, ScanBuilder, Statistics => 
V2Statistics, SupportsPushDownAggregates, SupportsPushDownFilters, 
SupportsPushDownJoin, SupportsPushDownRequiredColumns, 
SupportsPushDownV2Filters, SupportsPushDownVariantExtractions, 
SupportsReportStatistics, V1Scan, VariantExtraction}
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder, ScanContext, 
Statistics => V2Statistics, SupportsPushDownAggregates, 
SupportsPushDownFilters, SupportsPushDownJoin, SupportsPushDownRequiredColumns, 
SupportsPushDownV2Filters, SupportsPushDownVariantExtractions, 
SupportsReportStatistics, V1Scan, VariantExtraction}
 import org.apache.spark.sql.execution.datasources.{DataSourceStrategy, 
VariantInRelation, VariantMetadata}
 import org.apache.spark.sql.internal.SQLConf
 import 
org.apache.spark.sql.internal.connector.{SupportsPushDownCatalystFilters, 
VariantExtractionImpl}
@@ -48,9 +48,12 @@ import org.apache.spark.util.Utils
 object V2ScanRelationPushDown extends Rule[LogicalPlan] with PredicateHelper {
   import DataSourceV2Implicits._
 
-  def apply(plan: LogicalPlan): LogicalPlan = {
+  def apply(plan: LogicalPlan): LogicalPlan =
+    applyWithContext(plan, isSubquery = plan.isInstanceOf[Subquery])
+
+  private def applyWithContext(plan: LogicalPlan, isSubquery: Boolean): 
LogicalPlan = {
     val pushdownRules = Seq[LogicalPlan => LogicalPlan] (
-      createScanBuilder,
+      createScanBuilder(_, isSubquery),
       pushDownSample,
       pushDownFilters,
       pushDownJoin,
@@ -79,13 +82,17 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan] 
with PredicateHelper {
    * translation, column pruning, the iterative PartitionPredicate second 
pass) stays owned here.
    * `conditions` are pushed as a single `Filter` directly above the relation; 
the caller decides
    * which of them must come back fully enforced.
+   * `isSubquery` describes the destination plan's scope, absent from this 
synthetic input.
    */
   def rebuildScan(
       relation: DataSourceV2Relation,
       projectList: Seq[NamedExpression],
-      conditions: Seq[Expression]): Option[DataSourceV2ScanRelation] = {
+      conditions: Seq[Expression],
+      isSubquery: Boolean): Option[DataSourceV2ScanRelation] = {
     val child = conditions.reduceOption(And).map(Filter(_, 
relation)).getOrElse(relation)
-    apply(Project(projectList, child)).collectFirst { case s: 
DataSourceV2ScanRelation => s }
+    applyWithContext(Project(projectList, child), isSubquery).collectFirst {
+      case scan: DataSourceV2ScanRelation => scan
+    }
   }
 
   private def collapseGroupedSumOfCount(plan: LogicalPlan): LogicalPlan = {
@@ -97,9 +104,12 @@ object V2ScanRelationPushDown extends Rule[LogicalPlan] 
with PredicateHelper {
     }
   }
 
-  private def createScanBuilder(plan: LogicalPlan) = plan.transform {
+  private def createScanBuilder(
+      plan: LogicalPlan,
+      isSubquery: Boolean): LogicalPlan = plan.transform {
     case r: DataSourceV2Relation =>
-      ScanBuilderHolder(r.output, r, 
r.table.asReadable.newScanBuilder(r.options))
+      val context = new ScanContext(isSubquery)
+      ScanBuilderHolder(r.output, r, 
r.table.asReadable.newScanBuilder(context, r.options))
   }
 
   private def pushDownFilters(plan: LogicalPlan) = plan.transform {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
index bc57176e98dc..3bbf52a646be 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala
@@ -903,7 +903,10 @@ class PlanMerger(
     // here: SPARK-58207 keeps non-deterministic filters from being pushed to 
a V2 source, so an
     // offered one is simply not pushed (and dropped when the scan is 
extracted).
     val conds = strictFilters ++ 
bestEffortFilter.filter(_.references.subsetOf(relationOut))
-    V2ScanRelationPushDown.rebuildScan(relation, unionAttrs, conds).filter { 
scan =>
+    // Spark reads the merged result through scalar subqueries, even when the 
original
+    // scans came from the main query. Rebuild the scan with the destination's 
context.
+    V2ScanRelationPushDown.rebuildScan(
+      relation, unionAttrs, conds, isSubquery = true).filter { scan =>
       // The rebuilt scan must itself be mergeable. rebuildScan re-runs the 
full pushdown, so any
       // non-reproducible pushdown it introduces would make the merged scan 
unsound. Today's
       // Project-over-Filter input only triggers the plain path (always 
mergeable), so this is
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDownSubquerySuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDownSubquerySuite.scala
new file mode 100644
index 000000000000..54744be9f70f
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanRelationPushDownSubquerySuite.scala
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.datasources.v2
+
+import java.util
+import java.util.OptionalLong
+
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.QueryTest
+import org.apache.spark.sql.catalyst.expressions.{GreaterThan, Literal}
+import org.apache.spark.sql.catalyst.plans.logical.{Filter, LogicalPlan, 
Project, Subquery}
+import org.apache.spark.sql.connector.catalog.{Identifier, InMemoryTable, 
InMemoryTableCatalog, SupportsRead, Table, TableCapability}
+import org.apache.spark.sql.connector.expressions.aggregate.Aggregation
+import org.apache.spark.sql.connector.read.{Batch, Scan, ScanBuilder, 
ScanContext, Statistics, SupportsPushDownAggregates, SupportsPushDownFilters, 
SupportsPushDownRequiredColumns, SupportsReportStatistics}
+import org.apache.spark.sql.sources
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, StructType}
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+class V2ScanRelationPushDownSubquerySuite extends QueryTest with 
SharedSparkSession {
+  import SubqueryScanTestCatalog.calls
+
+  override protected def sparkConf: SparkConf = super.sparkConf.set(
+    "spark.sql.catalog.subquery_stats", 
classOf[SubqueryScanTestCatalog].getName)
+
+  override protected def beforeEach(): Unit = {
+    super.beforeEach()
+    calls.clear()
+  }
+
+  private def newRelation(
+      options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty): 
DataSourceV2Relation = {
+    val schema = new StructType().add("id", IntegerType).add("value", 
IntegerType)
+    val table = new InMemoryTable("test", schema, Array.empty, 
util.Collections.emptyMap())
+    DataSourceV2Relation.create(new SubqueryScanTestTable(table), None, None, 
options)
+  }
+
+  private def optimize(query: String): LogicalPlan = 
sql(query).queryExecution.optimizedPlan
+
+  private def assertContexts(expected: Set[Boolean]): Unit = {
+    assert(calls.nonEmpty)
+    assert(calls.forall(_.context.isDefined))
+    assert(calls.map(_.context.get.isInSubquery()).toSet == expected)
+    calls.indices.foreach { i =>
+      (0 until i).foreach { j => assert(calls(i).context.get ne 
calls(j).context.get) }
+    }
+  }
+
+  test("scan context equality, hashing and string representation") {
+    for (inSubquery <- Seq(false, true)) {
+      val context = new ScanContext(inSubquery)
+      val sameContext = new ScanContext(inSubquery)
+      assert(context.equals(context))
+      assert(context == sameContext)
+      assert(context.hashCode() == sameContext.hashCode())
+      assert(context != new ScanContext(!inSubquery))
+      assert(!context.equals(null))
+      assert(!context.equals(new Object))
+      assert(context.toString == s"ScanContext{isInSubquery=$inSubquery}")
+    }
+  }
+
+  test("the context overload delegates to an existing options-only 
implementation") {
+    val options = new CaseInsensitiveStringMap(Map("MiXeD" -> "value").asJava)
+    var receivedOptions: CaseInsensitiveStringMap = null
+    val table: SupportsRead = new InMemoryTable(
+        "legacy", new StructType().add("id", IntegerType), Array.empty,
+        util.Collections.emptyMap()) {
+      override def newScanBuilder(options: CaseInsensitiveStringMap): 
ScanBuilder = {
+        receivedOptions = options
+        super.newScanBuilder(options)
+      }
+    }
+    table.newScanBuilder(new ScanContext(true), options).build()
+    assert(receivedOptions eq options)
+    assert(receivedOptions.get("mixed") == "value")
+  }
+
+  test("direct options-only calls leave subquery context unspecified") {
+    val relation = newRelation()
+    val table = relation.table.asInstanceOf[SupportsRead]
+    table.newScanBuilder(relation.options).build()
+    assert(calls.size == 1)
+    assert(calls.head.context.isEmpty)
+    assert(calls.head.options eq relation.options)
+  }
+
+  for (inSubquery <- Seq(false, true)) {
+    test(s"builder context is available before pushdown: 
inSubquery=$inSubquery") {
+      val input = Map("MiXeD" -> "value", "isInSubquery" -> "user option")
+      val options = new CaseInsensitiveStringMap(input.asJava)
+      val relation = newRelation(options)
+      val plan = Project(Seq(relation.output.head),
+        Filter(GreaterThan(relation.output.head, Literal(0)), relation))
+      val result = V2ScanRelationPushDown(
+        if (inSubquery) Subquery(plan, correlated = false) else plan)
+
+      assertContexts(Set(inSubquery))
+      val call = calls.head
+      assert(call.options eq options)
+      assert(options.asCaseSensitiveMap().asScala.toMap == input)
+      assert(call.events.head == "select statistics")
+      assert(call.events.contains("filters"))
+      assert(call.events.contains("prune"))
+      assert(call.requiredSchema.fieldNames.toSeq == Seq("id"))
+      val scan = result.collectFirst { case r: DataSourceV2ScanRelation => 
r.scan }.get
+      val stats = 
scan.asInstanceOf[SupportsReportStatistics].estimateStatistics()
+      assert(stats.sizeInBytes().getAsLong == (if (inSubquery) 10L else 100L))
+    }
+  }
+
+  test("each planning pass receives a fresh context even when it reuses a 
relation") {
+    val relation = newRelation()
+    V2ScanRelationPushDown(relation)
+    V2ScanRelationPushDown(Subquery(relation, correlated = false))
+    V2ScanRelationPushDown(relation)
+    assert(calls.map(_.context.get.isInSubquery()).toSeq == Seq(false, true, 
false))
+    assertContexts(Set(false, true))
+    assert(calls.forall(_.options eq relation.options))
+  }
+
+  test("rebuilding a scan uses its destination context") {
+    val relation = newRelation()
+    V2ScanRelationPushDown(Subquery(relation, correlated = false))
+    for (inSubquery <- Seq(false, true)) {
+      val rebuilt = V2ScanRelationPushDown.rebuildScan(
+        relation, Seq(relation.output.head), Nil, isSubquery = inSubquery)
+      assert(rebuilt.isDefined)
+      assert(calls.last.context.get.isInSubquery() == inSubquery)
+      assert(calls.last.options eq relation.options)
+    }
+    assertContexts(Set(false, true))
+  }
+
+  test("scalar, IN and EXISTS subqueries provide context during SQL 
optimization") {
+    withTable("subquery_stats.t") {
+      sql("CREATE TABLE subquery_stats.t (id INT, value INT) USING foo")
+      Seq(
+        "SELECT (SELECT max(id) FROM subquery_stats.t)",
+        "SELECT 1 WHERE 1 IN (SELECT id FROM subquery_stats.t)",
+        "SELECT 1 WHERE EXISTS (SELECT id FROM subquery_stats.t)").foreach { 
query =>
+        calls.clear()
+        optimize(query)
+        assertContexts(Set(true))
+      }
+
+      calls.clear()
+      optimize("SELECT max(id) FROM subquery_stats.t")
+      assertContexts(Set(false))
+      assert(calls.exists(_.events.contains("aggregate")))
+
+      calls.clear()
+      optimize("SELECT (SELECT max(id) FROM subquery_stats.t)")
+      assertContexts(Set(true))
+      assert(calls.exists(_.events.contains("aggregate")))
+    }
+  }
+
+  test("the same table can be planned in the main query and a scalar 
subquery") {
+    withTable("subquery_stats.t") {
+      sql("CREATE TABLE subquery_stats.t (id INT, value INT) USING foo")
+      optimize("SELECT id, (SELECT max(id) FROM subquery_stats.t) FROM 
subquery_stats.t")
+      assertContexts(Set(false, true))
+    }
+  }
+
+  for (global <- Seq(false, true)) {
+    test(s"temporary view reuse preserves the planning context: 
global=$global") {
+      val viewName = "subquery_stats_view"
+      val qualifiedView = if (global) s"global_temp.$viewName" else viewName
+      def withView(f: => Unit): Unit = {
+        if (global) withGlobalTempView(viewName)(f) else 
withTempView(viewName)(f)
+      }
+      withTable("subquery_stats.t") {
+        withView {
+          sql("CREATE TABLE subquery_stats.t (id INT, value INT) USING foo")
+          val df = sql("SELECT id, (SELECT max(id) FROM subquery_stats.t) AS 
maximum " +
+            "FROM subquery_stats.t")
+          df.queryExecution.optimizedPlan
+          assertContexts(Set(false, true))
+          if (global) df.createOrReplaceGlobalTempView(viewName)
+          else df.createOrReplaceTempView(viewName)
+
+          calls.clear()
+          optimize(s"SELECT * FROM $qualifiedView")
+          assertContexts(Set(false, true))
+
+          calls.clear()
+          optimize(s"SELECT (SELECT max(id) FROM $qualifiedView)")
+          assertContexts(Set(true))
+
+          calls.clear()
+          optimize(s"SELECT * FROM $qualifiedView")
+          assertContexts(Set(false, true))
+
+          val replacement = sql("SELECT id FROM subquery_stats.t")
+          if (global) replacement.createOrReplaceGlobalTempView(viewName)
+          else replacement.createOrReplaceTempView(viewName)
+          calls.clear()
+          optimize(s"SELECT * FROM $qualifiedView")
+          assertContexts(Set(false))
+        }
+      }
+    }
+  }
+}
+
+class SubqueryScanTestCatalog extends InMemoryTableCatalog {
+  override def loadTable(ident: Identifier): Table = {
+    new 
SubqueryScanTestTable(super.loadTable(ident).asInstanceOf[InMemoryTable])
+  }
+}
+
+object SubqueryScanTestCatalog {
+  val calls: ArrayBuffer[SubqueryScanTestCall] = ArrayBuffer.empty
+}
+
+class SubqueryScanTestCall(
+    val context: Option[ScanContext],
+    val options: CaseInsensitiveStringMap) {
+  val events: ArrayBuffer[String] = ArrayBuffer("select statistics")
+  val sizeInBytes: Long = if (context.exists(_.isInSubquery())) 10L else 100L
+  var requiredSchema: StructType = new StructType()
+}
+
+class SubqueryScanTestTable(table: InMemoryTable) extends Table with 
SupportsRead {
+  override def name(): String = table.name
+  override def schema(): StructType = table.schema()
+  override def capabilities(): util.Set[TableCapability] = {
+    util.EnumSet.of(TableCapability.BATCH_READ)
+  }
+
+  override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder 
= {
+    newBuilder(None, options)
+  }
+
+  override def newScanBuilder(
+      context: ScanContext,
+      options: CaseInsensitiveStringMap): ScanBuilder = {
+    newBuilder(Some(context), options)
+  }
+
+  private def newBuilder(
+      context: Option[ScanContext],
+      options: CaseInsensitiveStringMap): ScanBuilder = {
+    val call = new SubqueryScanTestCall(context, options)
+    SubqueryScanTestCatalog.calls += call
+    val delegate = table.newScanBuilder(options)
+    new ScanBuilder with SupportsPushDownRequiredColumns with 
SupportsPushDownFilters
+        with SupportsPushDownAggregates {
+      override def pruneColumns(requiredSchema: StructType): Unit = {
+        call.events += "prune"
+        call.requiredSchema = requiredSchema
+        
delegate.asInstanceOf[SupportsPushDownRequiredColumns].pruneColumns(requiredSchema)
+      }
+
+      override def pushFilters(filters: Array[sources.Filter]): 
Array[sources.Filter] = {
+        call.events += "filters"
+        delegate.asInstanceOf[SupportsPushDownFilters].pushFilters(filters)
+      }
+
+      override def pushedFilters(): Array[sources.Filter] = {
+        delegate.asInstanceOf[SupportsPushDownFilters].pushedFilters()
+      }
+
+      override def pushAggregation(aggregation: Aggregation): Boolean = {
+        call.events += "aggregate"
+        false
+      }
+
+      override def build(): Scan = {
+        call.events += "build"
+        val scan = delegate.build()
+        new Scan with SupportsReportStatistics {
+          override def readSchema(): StructType = scan.readSchema()
+          override def toBatch: Batch = scan.toBatch
+          override def estimateStatistics(): Statistics = new Statistics {
+            override def sizeInBytes(): OptionalLong = 
OptionalLong.of(call.sizeInBytes)
+            override def numRows(): OptionalLong = OptionalLong.empty()
+          }
+        }
+      }
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
index 564fd617d6d1..2f2a8082978d 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala
@@ -19,6 +19,8 @@ package org.apache.spark.sql.execution.planmerging
 
 import java.util.OptionalLong
 
+import scala.collection.mutable.ArrayBuffer
+
 import org.apache.spark.sql.catalyst.dsl.expressions._
 import org.apache.spark.sql.catalyst.dsl.plans._
 import org.apache.spark.sql.catalyst.expressions.{Alias, And, Ascending, 
Attribute, AttributeReference, CreateNamedStruct, Expression, ExpressionSet, 
ExprId, GetStructField, GreaterThan, If, LessThan, Literal, Or, ScalarSubquery, 
SortOrder, TransformExpression}
@@ -31,7 +33,7 @@ import 
org.apache.spark.sql.connector.catalog.{FunctionCatalog, Identifier, Supp
 import org.apache.spark.sql.connector.catalog.functions.{BoundFunction, 
ScalarFunction, UnboundFunction}
 import org.apache.spark.sql.connector.expressions.{Expressions, 
FieldReference, SortDirection => V2SortDirection, SortOrder => V2SortOrder}
 import org.apache.spark.sql.connector.expressions.filter.Predicate
-import org.apache.spark.sql.connector.read.{Scan, ScanBuilder, Statistics => 
V2Statistics, SupportsPushDownLimit, SupportsPushDownOffset, 
SupportsPushDownRequiredColumns, SupportsPushDownTableSample, 
SupportsPushDownTopN, SupportsPushDownV2Filters, SupportsReportOrdering, 
SupportsReportPartitioning, SupportsReportStatistics}
+import org.apache.spark.sql.connector.read.{Scan, ScanBuilder, ScanContext, 
Statistics => V2Statistics, SupportsPushDownLimit, SupportsPushDownOffset, 
SupportsPushDownRequiredColumns, SupportsPushDownTableSample, 
SupportsPushDownTopN, SupportsPushDownV2Filters, SupportsReportOrdering, 
SupportsReportPartitioning, SupportsReportStatistics}
 import 
org.apache.spark.sql.connector.read.partitioning.{KeyGroupedPartitioning => 
V2KeyGroupedPartitioning, Partitioning => V2Partitioning, UnknownPartitioning 
=> V2UnknownPartitioning}
 import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation, 
DataSourceV2ScanRelation, V2ScanPartitioningAndOrdering, V2ScanRelationPushDown}
 import org.apache.spark.sql.internal.SQLConf
@@ -2527,6 +2529,38 @@ class MergeSubplansSuite extends PlanTest {
     comparePlans(Optimize.execute(originalQuery.analyze), 
correctAnswer.analyze)
   }
 
+  test("merged DSv2 scans receive subquery context for main-query aggregates") 
{
+    val scanContexts = ArrayBuffer.empty[Boolean]
+    val table = new TestV2Table(StructType(Seq(
+      StructField("a", IntegerType), StructField("b", IntegerType)))) {
+      override def newScanBuilder(
+          context: ScanContext,
+          options: CaseInsensitiveStringMap): ScanBuilder = {
+        scanContexts += context.isInSubquery()
+        super.newScanBuilder(options)
+      }
+    }
+
+    def aggregate(column: String): LogicalPlan = {
+      val relation = DataSourceV2Relation.create(table, None, None)
+      val attribute = relation.output.find(_.name == column).get
+      val pushed = V2ScanRelationPushDown(Project(Seq(attribute), relation))
+      pushed.groupBy()(sum(attribute).as(s"sum_$column"))
+    }
+
+    val originalQuery = aggregate("a").join(aggregate("b")).analyze
+    assert(scanContexts.toSeq == Seq(false, false))
+    scanContexts.clear()
+
+    // Merging moves these aggregates into scalar subqueries and rebuilds 
their shared scan.
+    val optimized = Optimize.execute(originalQuery)
+    assert(optimized.isInstanceOf[WithCTE])
+    val scans = v2Scans(optimized)
+    assert(scans.size == 1, s"expected the scans to merge:\n$optimized")
+    assert(scans.head.output.map(_.name).toSet == Set("a", "b"))
+    assert(scanContexts.nonEmpty && scanContexts.forall(identity))
+  }
+
   gridTest("SPARK-58892: inferred filters survive rebuilding a merged scan")(
       Seq(false, true)) { estimateInferred =>
     val table = new TestInferredFilterTable(estimateInferred)


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

Reply via email to