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

dongjoon-hyun 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 9de605f012ec [SPARK-59721][SQL] Ignore reported partitioning and 
ordering that reference unresolvable columns
9de605f012ec is described below

commit 9de605f012ec8689dda0acb21103cc5744c85981
Author: Anurag Mantripragada <[email protected]>
AuthorDate: Mon Oct 5 10:04:38 2026 -0700

    [SPARK-59721][SQL] Ignore reported partitioning and ordering that reference 
unresolvable columns
    
    ### What changes were proposed in this pull request?
    
    When a V2 scan reports a `KeyGroupedPartitioning` or an `ordering` that 
references a column the relation can't resolve, the query used to fail with 
`Unable to resolve X given [...]`. This PR makes 
`V2ScanPartitioningAndOrdering` check the columns referenced by the reported 
partition keys and ordering (recursively, including nested transform arguments) 
against the relation before converting them. If any can't be resolved, 
including a missing nested field, an ambiguous name, or an empty [...]
    
    This PR adds a `resolveRefOpt`, and makes `resolveRef` a thin wrapper 
around it. `resolveRefOpt` still throws for a missing nested field or an 
ambiguous name, so the rule treats those errors as unresolvable and includes 
the error condition in the warning. The `toCatalyst*` methods are unchanged.
    
    Keys whose columns all resolve take the existing conversion path unchanged, 
so an unsupported expression type (e.g. `id + 1`) or a nested transform whose 
function can't be loaded still fails the query as before. Because the whole 
report is checked before any key is converted, a report that also references an 
unresolvable column falls back with a warning instead, e.g. `[id + 1, 
identity(missing)]`.
    
    The Javadoc of `KeyGroupedPartitioning#keys()` and 
`SupportsReportOrdering#outputOrdering()` now says that Spark resolves the 
column references against the table columns, including columns pruned from the 
scan output, and ignores the whole report if any of them can't be resolved. The 
docs of `spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled` now 
mention that an ignored ordering can be replaced by one derived from the 
partition keys.
    
    ### Why are the changes needed?
    
    Found while reviewing SPARK-58111 ([PR 
#55518](https://github.com/apache/spark/pull/55518)). This is a general, 
pre-existing bug in how `V2ScanPartitioningAndOrdering` handles reported 
partitioning and ordering, not something introduced by #55518.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A query against a data source whose scan reports a 
`KeyGroupedPartitioning` with a key that can't be resolved previously failed 
outright. It now succeeds, with partitioning degraded to `UnknownPartitioning` 
instead, and Spark logs a warning naming the relation, the scan class, and the 
columns that can't be resolved.
    
    The same applies to an ambiguous or empty column name. A report that 
combines an unsupported expression with an unresolvable column, e.g. `missing + 
1` or `[id + 1, identity(missing)]`, used to fail with 
`_LEGACY_ERROR_TEMP_3054` and now falls back with a warning. An unsupported 
expression over resolvable columns still fails as before.
    
    If the scan keeps its partitioning but its reported ordering is ignored, 
Spark derives the ordering from the partition keys when 
`spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled` is on (the 
default).
    
    ### How was this patch tested?
    
    New tests in `V2ExpressionUtilsSuite`:
    - `resolveRefOpt` returns `None` for an unresolvable reference.
    - `resolveRefOpt` throws `FIELD_NOT_FOUND` for a missing nested field and 
`AMBIGUOUS_REFERENCE` for an ambiguous reference.
    
    New tests in `KeyGroupedPartitioningSuite`:
    - A join against a table whose scan reports unresolvable partition keys 
succeeds, plans a shuffle instead of a storage-partitioned join, and logs a 
warning naming the relation, the scan class, and only the unresolvable columns. 
The cases are:
      - a `bucket` key, a nested transform key, and a multi-key partitioning 
with one bad key;
      - duplicate references, and two distinct unresolvable columns;
      - a missing nested field;
      - an unsupported expression next to or over an unresolvable column (`[id 
+ 1, identity(missing)]`, `missing + 1`);
      - a connector-defined transform that hides the reference from 
`children()`.
    
      Resolvable keys (`id`, case-insensitive `ID`, and a connector-defined 
transform with an extra reference only in `children()`) keep the partitioning 
and plan a storage-partitioned join without a shuffle.
    - A reported ordering that can't be resolved is ignored as a whole with a 
warning, and the scan still derives `id ASC` from its kept partitioning. The 
cases are a column that doesn't exist, one hidden from a connector-defined sort 
order's `children()`, an empty name, and a two-key ordering with one 
unresolvable key (`[id, missing]`). Resolvable orderings (`id`, 
case-insensitive `ID`, the metadata column `index`, and a connector-defined 
sort order with an extra reference only in `child [...]
    - A reported partition key of an unsupported expression type (`id + 1`), or 
a nested transform whose function can't be loaded (`f(g(id))`), still fails the 
query with `_LEGACY_ERROR_TEMP_3054`.
    
    New test in `DataSourceV2Suite`:
    - An unresolvable ordering reported through connector-defined `SortOrder`, 
`Transform`, and `NamedReference` classes (`OrderAndPartitionAwareDataSource` 
and its Java counterpart) is ignored with a warning, and the scan relation 
keeps no ordering.
    
    Also ran `V2ExpressionUtilsSuite`, `KeyGroupedPartitioningSuite`, 
`WriteDistributionAndOrderingSuite`, `DataSourceV2Suite`, `MergeSubplansSuite` 
(comment update only), `SQLConfSuite`, and 
`ProjectedOrderingAndPartitioningSuite` to confirm no regression in the 
partitioning/SPJ, ordering, and write paths this change touches.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Sonnet 5, Opus 5.5)
    Verified manually by me.
    
    Closes #58979 from anuragmantri/v2expr-opt-resolve-degrade.
    
    Authored-by: Anurag Mantripragada <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 docs/sql-performance-tuning.md                     |   2 +-
 .../sql/connector/read/SupportsReportOrdering.java |   4 +
 .../read/partitioning/KeyGroupedPartitioning.java  |   4 +
 .../catalyst/expressions/V2ExpressionUtils.scala   |  23 +-
 .../org/apache/spark/sql/internal/SQLConf.scala    |   3 +-
 .../expressions/V2ExpressionUtilsSuite.scala       |  28 ++-
 .../datasources/v2/DataSourceV2ScanExecBase.scala  |  18 +-
 .../v2/V2ScanPartitioningAndOrdering.scala         |  80 ++++++-
 .../sql/execution/planmerging/PlanMerger.scala     |  13 +-
 .../spark/sql/connector/DataSourceV2Suite.scala    |  29 +++
 .../connector/KeyGroupedPartitioningSuite.scala    | 239 ++++++++++++++++++++-
 .../execution/planmerging/MergeSubplansSuite.scala |  10 +-
 12 files changed, 409 insertions(+), 44 deletions(-)

diff --git a/docs/sql-performance-tuning.md b/docs/sql-performance-tuning.md
index 5e9f7aae05f0..232c47d16b13 100644
--- a/docs/sql-performance-tuning.md
+++ b/docs/sql-performance-tuning.md
@@ -708,7 +708,7 @@ The following SQL properties enable Storage Partition Join 
in different join que
       
<td><code>spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled</code></td>
       <td>true</td>
       <td>
-        When enabled, Spark derives the output ordering of a V2 scan from its 
partition key expressions, if the source reports a keyed partitioning but no 
explicit ordering. All rows of such a partition share one key value, so the 
partition is trivially sorted by those expressions, and a sort Spark would 
otherwise add becomes unnecessary. This config requires 
<code>spark.sql.sources.v2.bucketing.enabled</code> to be true.
+        When enabled, Spark derives the output ordering of a V2 scan from its 
partition key expressions, if the source reports a keyed partitioning but no 
explicit ordering, or an ordering that Spark ignores because it references a 
column that cannot be resolved. All rows of such a partition share one key 
value, so the partition is trivially sorted by those expressions, and a sort 
Spark would otherwise add becomes unnecessary. This config requires 
<code>spark.sql.sources.v2.bucketing.ena [...]
       </td>
       <td>4.2.0</td>
     </tr>
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsReportOrdering.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsReportOrdering.java
index 0dc102d11c21..cf1a0532d3c0 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsReportOrdering.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/SupportsReportOrdering.java
@@ -35,6 +35,10 @@ public interface SupportsReportOrdering extends Scan {
 
   /**
    * Returns the order in each partition of this data source scan.
+   * <p>
+   * Spark resolves the column references in these sort orders against the 
table columns,
+   * including columns pruned from the scan output. If any of them cannot be 
resolved, Spark
+   * ignores the whole reported ordering and logs a warning.
    */
   SortOrder[] outputOrdering();
 }
diff --git 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/partitioning/KeyGroupedPartitioning.java
 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/partitioning/KeyGroupedPartitioning.java
index 552d92ad0e8b..88ee89af8329 100644
--- 
a/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/partitioning/KeyGroupedPartitioning.java
+++ 
b/sql/catalyst/src/main/java/org/apache/spark/sql/connector/read/partitioning/KeyGroupedPartitioning.java
@@ -43,6 +43,10 @@ public class KeyGroupedPartitioning implements Partitioning {
 
   /**
    * Returns the partition transform expressions for this partitioning.
+   * <p>
+   * Spark resolves the column references in these expressions against the 
table columns,
+   * including columns pruned from the scan output. If any of them cannot be 
resolved, Spark
+   * ignores the whole reported partitioning and logs a warning.
    */
   public Expression[] keys() {
     return keys;
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
index af84df8fcc06..2ca5a499b84e 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtils.scala
@@ -46,15 +46,22 @@ import org.apache.spark.util.ArrayImplicits._
 object V2ExpressionUtils extends SQLConfHelper with Logging {
   import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
 
+  /**
+   * Variant of `resolveRef` that returns `None` if no attribute matches the 
reference. It still
+   * throws for a nested-field extraction error, such as a missing nested 
field, or an ambiguous
+   * reference.
+   */
+  private[sql] def resolveRefOpt(
+      ref: NamedReference, plan: LogicalPlan): Option[NamedExpression] = {
+    plan.resolve(ref.fieldNames.toImmutableArraySeq, conf.resolver)
+  }
+
   def resolveRef[T <: NamedExpression](ref: NamedReference, plan: 
LogicalPlan): T = {
-    plan.resolve(ref.fieldNames.toImmutableArraySeq, conf.resolver) match {
-      case Some(namedExpr) =>
-        namedExpr.asInstanceOf[T]
-      case None =>
-        val name = ref.fieldNames.toImmutableArraySeq.quoted
-        val outputString = plan.output.map(_.name).mkString(",")
-        throw QueryCompilationErrors.cannotResolveAttributeError(name, 
outputString)
-    }
+    resolveRefOpt(ref, plan).getOrElse {
+      val name = ref.fieldNames.toImmutableArraySeq.quoted
+      val outputString = plan.output.map(_.name).mkString(",")
+      throw QueryCompilationErrors.cannotResolveAttributeError(name, 
outputString)
+    }.asInstanceOf[T]
   }
 
   def resolveRefs[T <: NamedExpression](refs: Seq[NamedReference], plan: 
LogicalPlan): Seq[T] = {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index b4254747225a..8edf1d7cf899 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -2590,7 +2590,8 @@ object SQLConf {
     buildConf("spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled")
       .doc("When enabled, Spark derives output ordering from the partition key 
expressions of " +
         "a V2 data source that reports a KeyedPartitioning but does not report 
explicit ordering " +
-        "via SupportsReportOrdering. Within a single partition all rows share 
the same key " +
+        "via SupportsReportOrdering, or reports one that Spark ignores because 
it references a " +
+        "column that cannot be resolved. Within a single partition all rows 
share the same key " +
         s"value, so the data is trivially sorted by those expressions. 
Requires " +
         s"${V2_BUCKETING_ENABLED.key} to be enabled.")
       .version("4.2.0")
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtilsSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtilsSuite.scala
index d1c23d68555a..899ddf5e9d3b 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtilsSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/V2ExpressionUtilsSuite.scala
@@ -21,7 +21,7 @@ import org.apache.spark.SparkFunSuite
 import org.apache.spark.sql.AnalysisException
 import org.apache.spark.sql.catalyst.plans.logical.LocalRelation
 import org.apache.spark.sql.connector.expressions._
-import org.apache.spark.sql.types.StringType
+import org.apache.spark.sql.types.{IntegerType, StringType, StructType}
 
 class V2ExpressionUtilsSuite extends SparkFunSuite {
 
@@ -37,4 +37,30 @@ class V2ExpressionUtilsSuite extends SparkFunSuite {
     }
     assert(exc.message.contains("v2Fun(a) ASC NULLS FIRST is not currently 
supported"))
   }
+
+  test("SPARK-59721: resolveRefOpt returns None for an unresolvable 
reference") {
+    val plan = LocalRelation(AttributeReference("a", StringType)())
+    assert(V2ExpressionUtils.resolveRefOpt(FieldReference("a"), 
plan).isDefined)
+    assert(V2ExpressionUtils.resolveRefOpt(FieldReference("missing"), 
plan).isEmpty)
+  }
+
+  test("SPARK-59721: resolveRefOpt throws for a missing nested field or an 
ambiguous reference") {
+    val structPlan =
+      LocalRelation(AttributeReference("s", new StructType().add("x", 
IntegerType))())
+    checkError(
+      exception = intercept[AnalysisException] {
+        V2ExpressionUtils.resolveRefOpt(FieldReference("s.missing"), 
structPlan)
+      },
+      condition = "FIELD_NOT_FOUND",
+      parameters = Map("fieldName" -> "`missing`", "fields" -> "`x`"))
+
+    val ambiguousPlan = LocalRelation(
+      AttributeReference("a", StringType)(), AttributeReference("a", 
StringType)())
+    checkError(
+      exception = intercept[AnalysisException] {
+        V2ExpressionUtils.resolveRefOpt(FieldReference("a"), ambiguousPlan)
+      },
+      condition = "AMBIGUOUS_REFERENCE",
+      parameters = Map("name" -> "`a`", "referenceNames" -> "[`a`, `a`]"))
+  }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala
index 5d1617111885..49bee1e7961c 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DataSourceV2ScanExecBase.scala
@@ -135,15 +135,15 @@ trait DataSourceV2ScanExecBase
 
   /**
    * Returns the output ordering for this scan. When the source reports 
ordering via
-   * `SupportsReportOrdering`, that ordering may reference columns pruned out 
of the scan output
-   * (see V2ScanPartitioningAndOrdering), while a consumer binds it against 
the output, e.g. the
-   * k-way merge of `GroupPartitionsExec`. Ordering is prefix-based, so the 
leading run of sort
-   * orders over the output is kept and the rest is dropped, except the sort 
orders on a partition
-   * key when the output partitioning is a `KeyedPartitioning`: each partition 
holds a single key,
-   * so those still hold. Otherwise, when the output partitioning is a 
`KeyedPartitioning` and
-   * `spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled` is on, each 
partition
-   * contains rows where the key expressions evaluate to a single constant 
value, so the data
-   * is trivially sorted by those expressions within the partition.
+   * `SupportsReportOrdering` and `V2ScanPartitioningAndOrdering` keeps it, 
that ordering may
+   * reference columns pruned out of the scan output, while a consumer binds 
it against the
+   * output, e.g. the k-way merge of `GroupPartitionsExec`. Ordering is 
prefix-based, so the
+   * leading run of sort orders over the output is kept and the rest is 
dropped, except the sort
+   * orders on a partition key when the output partitioning is a 
`KeyedPartitioning`: each
+   * partition holds a single key, so those still hold. Otherwise, when the 
output partitioning
+   * is a `KeyedPartitioning` and 
`spark.sql.sources.v2.bucketing.partitionKeyOrdering.enabled`
+   * is on, each partition contains rows where the key expressions evaluate to 
a single constant
+   * value, so the data is trivially sorted by those expressions within the 
partition.
    */
   override def outputOrdering: Seq[SortOrder] = {
     (ordering, outputPartitioning) match {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala
index 5cc5d2b494fe..d001b20b5d36 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/V2ScanPartitioningAndOrdering.scala
@@ -17,11 +17,14 @@
 package org.apache.spark.sql.execution.datasources.v2
 
 import org.apache.spark.internal.Logging
-import org.apache.spark.internal.LogKeys.CLASS_NAME
+import org.apache.spark.internal.LogKeys.{CLASS_NAME, COLUMN_NAMES, 
RELATION_NAME}
+import org.apache.spark.sql.AnalysisException
 import org.apache.spark.sql.catalyst.expressions.V2ExpressionUtils
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 import org.apache.spark.sql.catalyst.rules.Rule
 import 
org.apache.spark.sql.catalyst.trees.TreePattern.DATA_SOURCE_V2_SCAN_RELATION
+import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
+import org.apache.spark.sql.connector.expressions.{Expression => V2Expression, 
NamedReference, SortOrder => V2SortOrder, Transform}
 import org.apache.spark.sql.connector.read.{SupportsReportOrdering, 
SupportsReportPartitioning}
 import 
org.apache.spark.sql.connector.read.partitioning.{KeyGroupedPartitioning, 
UnknownPartitioning}
 import org.apache.spark.util.ArrayImplicits._
@@ -47,9 +50,19 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
         if d.keyGroupedPartitioning.isEmpty =>
       val catalystPartitioning = scan.outputPartitioning() match {
         case kgp: KeyGroupedPartitioning =>
-          val partitioning = sequenceToOption(
-            kgp.keys().map(V2ExpressionUtils.toCatalystOpt(_, relation, 
relation.funCatalog))
-              .toImmutableArraySeq)
+          val keys = kgp.keys().toImmutableArraySeq
+          val unresolvedColumns = unresolvableColumns(keys, relation)
+          val partitioning = if (unresolvedColumns.nonEmpty) {
+            logWarning(
+              log"Spark ignores the KeyGroupedPartitioning reported by " +
+                log"${MDC(RELATION_NAME, relation.name)} (scan " +
+                log"${MDC(CLASS_NAME, scan.getClass.getName)}) because the 
partition key columns " +
+                log"cannot be resolved: ${MDC(COLUMN_NAMES, 
unresolvedColumns.mkString(", "))}.")
+            None
+          } else {
+            sequenceToOption(
+              keys.map(V2ExpressionUtils.toCatalystOpt(_, relation, 
relation.funCatalog)))
+          }
           // Keep the partitioning when at least one of its keys is still in 
the scan output: the
           // scan projects the pruned key positions away when reporting its 
physical output
           // partitioning (see DataSourceV2ScanExecBase.outputPartitioning). 
When no key survives,
@@ -75,12 +88,57 @@ object V2ScanPartitioningAndOrdering extends 
Rule[LogicalPlan] with Logging {
   private def ordering(plan: LogicalPlan) = plan.transformDownWithPruning(
       _.containsPattern(DATA_SOURCE_V2_SCAN_RELATION)) {
     case d @ ExtractV2ScanInfo(relation, scan: SupportsReportOrdering, _) =>
-      // The ordering is kept as reported, even where it references columns 
pruned out of the scan
-      // output: truncating it here would also drop the sort orders on a 
partition key past a
-      // pruned column, which still hold. 
`DataSourceV2ScanRelation.doCanonicalize` and
-      // `DataSourceV2ScanExecBase.outputOrdering` restrict it to the scan 
output instead.
-      val ordering =
-        V2ExpressionUtils.toCatalystOrdering(scan.outputOrdering(), relation, 
relation.funCatalog)
-      d.copy(ordering = Some(ordering))
+      val reportedOrdering = scan.outputOrdering()
+      val unresolvedColumns = 
unresolvableColumns(reportedOrdering.toImmutableArraySeq, relation)
+      if (unresolvedColumns.nonEmpty) {
+        logWarning(
+          log"Spark ignores the ordering reported by ${MDC(RELATION_NAME, 
relation.name)} " +
+            log"(scan ${MDC(CLASS_NAME, scan.getClass.getName)}) because the 
ordering columns " +
+            log"cannot be resolved: ${MDC(COLUMN_NAMES, 
unresolvedColumns.mkString(", "))}.")
+        // Use None, not Some(Nil): only None lets 
DataSourceV2ScanExecBase.outputOrdering derive an
+        // ordering from a kept key-grouped partitioning, which does not 
depend on the dropped
+        // report.
+        d.copy(ordering = None)
+      } else {
+        // The ordering is kept as reported, even where it references columns 
pruned out of the
+        // scan output: truncating it here would also drop the sort orders on 
a partition key past a
+        // pruned column, which still hold. 
`DataSourceV2ScanRelation.doCanonicalize` and
+        // `DataSourceV2ScanExecBase.outputOrdering` restrict it to the scan 
output instead.
+        val ordering =
+          V2ExpressionUtils.toCatalystOrdering(reportedOrdering, relation, 
relation.funCatalog)
+        d.copy(ordering = Some(ordering))
+      }
+  }
+
+  private def unresolvableColumns(
+      exprs: Seq[V2Expression],
+      relation: LogicalPlan): Seq[String] = {
+    // Walk the accessors the conversion reads, `Transform.arguments()` and
+    // `SortOrder.expression()`, instead of `V2Expression.references()` or 
`children()`: a connector
+    // can override those to disagree with the conversion, and 
`ApplyTransform.references()` returns
+    // only its top-level arguments, which would miss `f(g(missing))`. Other 
expressions, such as
+    // `GeneralScalarExpression`, which the conversion rejects, go through 
`children()`.
+    def collectReferences(expr: V2Expression): Seq[NamedReference] = expr 
match {
+      case ref: NamedReference => Seq(ref)
+      case t: Transform => 
t.arguments().toImmutableArraySeq.flatMap(collectReferences)
+      case s: V2SortOrder => collectReferences(s.expression())
+      case other => 
other.children().toImmutableArraySeq.flatMap(collectReferences)
+    }
+    exprs.flatMap(collectReferences).flatMap { ref =>
+      val parts = ref.fieldNames.toImmutableArraySeq
+      if (parts.isEmpty) {
+        // An empty name can be neither quoted nor resolved: both throw.
+        Some("<empty>")
+      } else {
+        val name = parts.quoted
+        try {
+          Option.when(V2ExpressionUtils.resolveRefOpt(ref, 
relation).isEmpty)(name)
+        } catch {
+          // A nested-field extraction error or an ambiguous reference throws 
instead of returning
+          // None.
+          case e: AnalysisException => Some(s"$name (${e.getCondition})")
+        }
+      }
+    }.distinct
   }
 }
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 0cf12e8b5e1d..2eb50ce63a4e 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
@@ -980,12 +980,13 @@ class PlanMerger(
   // Only the reported EXPRESSIONS are compared, which is all a 
DataSourceV2ScanRelation carries;
   // the merged scan's split count and partition values can still differ from 
an input's, since it
   // may push a different best-effort filter and so prune differently. And a 
report the merged scan
-  // GAINS is not a degradation either. For partitioning that is because an 
input dropped its own
-  // only where none of its keys survived in that scan's output 
(V2ScanPartitioningAndOrdering's
-  // partitioning pass keeps the report whenever any key survives), not 
because the source stopped
-  // reporting; the ordering pass has no such guard, so an ordering report is
-  // never dropped by pruning and a gained one can only come from the source. 
Either way, keeping it
-  // is exactly the win this merge is after.
+  // GAINS is not a degradation either. Both V2ScanPartitioningAndOrdering 
passes drop a report
+  // that references a column the relation cannot resolve, but that does not 
depend on pruning.
+  // Beyond that, an input dropped its partitioning only where none of its 
keys survived in that
+  // scan's output (the partitioning pass keeps the report whenever any key 
survives), not because
+  // the source stopped reporting; the ordering pass has no such guard, so an 
ordering report is
+  // never dropped by pruning and a gained one can only come from the source. 
Either way, keeping
+  // it is exactly the win this merge is after.
   private def mergeDegradesReporting(
       merged: DataSourceV2ScanRelation,
       requiredKeyGroupedPartitioning: Seq[Expression],
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
index 55febd40db47..e269e92a2442 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
@@ -24,6 +24,7 @@ import java.util.OptionalLong
 
 import scala.jdk.CollectionConverters._
 
+import org.apache.logging.log4j.Level
 import test.org.apache.spark.sql.connector._
 
 import org.apache.spark.{SparkException, SparkUnsupportedOperationException}
@@ -397,6 +398,34 @@ class DataSourceV2Suite extends SharedSparkSession with 
AdaptiveSparkPlanHelper
     }
   }
 
+  test("SPARK-59721: ignore an unresolvable ordering reported with 
connector-defined classes") {
+    Seq(
+      classOf[OrderAndPartitionAwareDataSource],
+      classOf[JavaOrderAndPartitionAwareDataSource]
+    ).foreach { cls =>
+      withClue(cls.getName) {
+        val df = spark.read
+          .option("partitionKeys", "i")
+          .option("orderKeys", "missing")
+          .format(cls.getName)
+          .load()
+        val logAppender = new LogAppender("unresolvable reported ordering")
+        withLogAppender(logAppender, level = Some(Level.WARN)) {
+          checkAnswer(df, Seq(Row(1, 4), Row(1, 5), Row(3, 5), Row(2, 6), 
Row(4, 1), Row(4, 2)))
+        }
+        val scanRelation = getScanRelation(df)
+        val expectedWarning =
+          s"Spark ignores the ordering reported by ${scanRelation.name} " +
+            s"(scan ${scanRelation.scan.getClass.getName}) because the 
ordering columns " +
+            "cannot be resolved: missing."
+        val warnings = 
logAppender.loggingEvents.map(_.getMessage.getFormattedMessage)
+          .filter(_.contains("columns cannot be resolved"))
+        assert(warnings.toSet == Set(expectedWarning), warnings)
+        assert(scanRelation.ordering.isEmpty)
+      }
+    }
+  }
+
   test ("statistics report data source") {
     Seq(classOf[ReportStatisticsDataSource], 
classOf[JavaReportStatisticsDataSource]).foreach {
       cls =>
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
index ea1fafd94cfe..0b037ed6829b 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala
@@ -19,20 +19,25 @@ package org.apache.spark.sql.connector
 import java.sql.Timestamp
 import java.util.Collections
 
+import org.apache.logging.log4j.Level
+
 import org.apache.spark.{SparkConf, SparkException}
 import org.apache.spark.rdd.SortedMergeCoalescedRDD
-import org.apache.spark.sql.{DataFrame, ExplainSuiteHelper, Row}
+import org.apache.spark.sql.{AnalysisException, DataFrame, ExplainSuiteHelper, 
Row}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{Ascending, 
AttributeReference, ExprId, Literal, TransformExpression}
 import org.apache.spark.sql.catalyst.expressions.aggregate.Complete
 import org.apache.spark.sql.catalyst.plans.{Cross, ExistenceJoin, Inner, 
JoinType, LeftAnti, LeftSemi, LeftSingle}
 import org.apache.spark.sql.catalyst.plans.physical
 import org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning
-import org.apache.spark.sql.connector.catalog.{Column, Identifier, 
InMemoryCatalystRuntimeFilterCatalog, InMemoryTableCatalog}
+import org.apache.spark.sql.connector.catalog.{Column, Identifier, 
InMemoryBaseTable, InMemoryCatalystRuntimeFilterCatalog, InMemoryTable, 
InMemoryTableCatalog}
+import org.apache.spark.sql.connector.catalog.constraints.Constraint
 import org.apache.spark.sql.connector.catalog.functions._
-import org.apache.spark.sql.connector.distributions.Distributions
+import org.apache.spark.sql.connector.distributions.{Distribution, 
Distributions}
 import org.apache.spark.sql.connector.expressions._
 import org.apache.spark.sql.connector.expressions.Expressions._
+import org.apache.spark.sql.connector.read.{Batch, Scan, ScanBuilder, 
SupportsReportOrdering, SupportsReportPartitioning}
+import 
org.apache.spark.sql.connector.read.partitioning.{KeyGroupedPartitioning, 
Partitioning}
 import org.apache.spark.sql.execution.{
   ExtendedMode,
   FormattedMode,
@@ -56,6 +61,7 @@ import org.apache.spark.sql.functions.{col, max}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.internal.SQLConf._
 import org.apache.spark.sql.types._
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.tags.ExtendedSQLTest
 
 abstract class KeyGroupedPartitioningSuiteBase extends 
DistributionAndOrderingSuiteBase {
@@ -3932,6 +3938,161 @@ class KeyGroupedPartitioningSuite
       Row(1, "bb", "bb"), Row(2, "cc", "cc")))
   }
 
+  private val customReportingCatalogName = "custom_reporting_cat"
+  private val customReportingTableName = 
s"$customReportingCatalogName.ns.$table"
+  private def plusOneKey(column: String): Expression =
+    new GeneralScalarExpression("+", Array[Expression](FieldReference(column), 
literal(1)))
+  private def nestedKey(column: String): Expression =
+    ApplyTransform("f", Seq(ApplyTransform("g", Seq(FieldReference(column)))))
+  private def ignoredReportWarning(report: String, kind: String, columns: 
String): String =
+    s"Spark ignores the $report reported by $customReportingTableName " +
+      s"(scan ${classOf[CustomReportingScan].getName}) because the $kind 
columns " +
+      s"cannot be resolved: $columns."
+
+  private def withCustomReportingTable(f: CustomReportingCatalog => Unit): 
Unit = {
+    withSQLConf(
+        s"spark.sql.catalog.$customReportingCatalogName" -> 
classOf[CustomReportingCatalog].getName,
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val reportingCatalog = 
spark.sessionState.catalogManager.catalog(customReportingCatalogName)
+        .asInstanceOf[CustomReportingCatalog]
+      try {
+        val reportingColumns = Array(
+          Column.create("id", IntegerType),
+          Column.create("data", StringType),
+          Column.create("s", new StructType().add("x", IntegerType)))
+        createTable(table, reportingColumns, Array(identity("id")), catalog = 
reportingCatalog)
+        sql(s"INSERT INTO $customReportingTableName VALUES " +
+            "(1, 'aa', named_struct('x', 10)), (2, 'bb', named_struct('x', 
20))")
+        createTable(table, columns, Array(identity("id")))
+        sql(s"INSERT INTO testcat.ns.$table VALUES " +
+            "(1, 'cc', cast('2020-01-03' as timestamp)), " +
+            "(2, 'dd', cast('2020-01-04' as timestamp))")
+        f(reportingCatalog)
+      } finally {
+        reportingCatalog.reportedKeys = Seq.empty
+        reportingCatalog.reportedOrdering = Seq.empty
+        reportingCatalog.clearTables()
+      }
+    }
+  }
+
+  private def customReportingJoinDf(): DataFrame = sql(
+    s"""SELECT t1.data, t2.data FROM $customReportingTableName t1
+       |JOIN testcat.ns.$table t2 ON t1.id = t2.id
+       |""".stripMargin)
+
+  private def customReportingJoin(): (SparkPlan, BatchScanExec, Seq[String]) = 
{
+    val df = customReportingJoinDf()
+    val logAppender = new LogAppender("custom reported partitioning or 
ordering")
+    withLogAppender(logAppender, level = Some(Level.WARN)) {
+      checkAnswer(df, Seq(Row("aa", "cc"), Row("bb", "dd")))
+    }
+    val warnings = 
logAppender.loggingEvents.map(_.getMessage.getFormattedMessage)
+      .filter(_.contains("columns cannot be resolved")).toSeq
+    val plan = df.queryExecution.executedPlan
+    val reportingScans = 
collectScans(plan).filter(_.scan.isInstanceOf[CustomReportingScan])
+    assert(reportingScans.length == 1)
+    (plan, reportingScans.head, warnings)
+  }
+
+  test("SPARK-59721: a reported partitioning with an unresolvable key is 
ignored") {
+    // CustomReportingCatalog is not a FunctionCatalog, so a `bucket` key 
would be dropped even with
+    // a resolvable column. The warning assertion shows the unresolvable 
column is what drops it.
+    val cases = Seq[(Seq[Expression], Option[String])](
+      (Seq(identity("id")), None),
+      (Seq(identity("ID")), None),
+      (Seq(bucket(4, "missing")), Some("missing")),
+      (Seq(nestedKey("missing")), Some("missing")),
+      (Seq(identity("id"), identity("missing")), Some("missing")),
+      (Seq(identity("missing"), bucket(4, "missing")), Some("missing")),
+      (Seq(identity("missing"), bucket(4, "absent"), identity("missing")),
+        Some("missing, absent")),
+      (Seq(plusOneKey("id"), identity("missing")), Some("missing")),
+      (Seq(plusOneKey("missing")), Some("missing")),
+      (Seq(identity("s.missing")), Some("s.missing (FIELD_NOT_FOUND)")),
+      (Seq(ChildrenOverridingTransform("identity", 
Seq(FieldReference("missing")), Seq.empty)),
+        Some("missing")),
+      (Seq(ChildrenOverridingTransform("identity", Seq(FieldReference("id")),
+        Seq(FieldReference("missing")))), None))
+    withCustomReportingTable { reportingCatalog =>
+      cases.foreach { case (keys, unresolvedColumns) =>
+        reportingCatalog.reportedKeys = keys
+        val (plan, reportingScan, warnings) = customReportingJoin()
+        val keysString = keys.map(_.describe()).mkString(", ")
+        unresolvedColumns match {
+          case Some(columnsString) =>
+            assert(warnings.toSet ==
+              Set(ignoredReportWarning("KeyGroupedPartitioning", "partition 
key", columnsString)),
+              keysString)
+            assert(reportingScan.keyGroupedPartitioning.isEmpty, keysString)
+            assert(collectShuffles(plan).nonEmpty, keysString)
+          case None =>
+            assert(warnings.isEmpty, warnings)
+            assert(reportingScan.keyGroupedPartitioning.isDefined, keysString)
+            assert(collectShuffles(plan).isEmpty, keysString)
+        }
+      }
+    }
+  }
+
+  test("SPARK-59721: a reported ordering with an unresolvable column is 
ignored") {
+    withCustomReportingTable { reportingCatalog =>
+      withSQLConf(SQLConf.V2_BUCKETING_PARTITION_KEY_ORDERING_ENABLED.key -> 
"true") {
+        reportingCatalog.reportedKeys = Seq(identity("id"))
+        Seq[(SortOrder, String)](
+          (sort(FieldReference("id"), SortDirection.ASCENDING), "id"),
+          (sort(FieldReference("ID"), SortDirection.ASCENDING), "ID"),
+          // `index` is a metadata column of InMemoryBaseTable.
+          (sort(FieldReference("index"), SortDirection.ASCENDING), "index"),
+          (ChildrenOverridingSortOrder(FieldReference("id"), 
Seq(FieldReference("missing"))), "id")
+        ).foreach { case (order, column) =>
+          reportingCatalog.reportedOrdering = Seq(order)
+          val (_, keptScan, keptWarnings) = customReportingJoin()
+          assert(keptWarnings.isEmpty, keptWarnings)
+          
assert(keptScan.ordering.map(_.map(_.child.references.map(_.name).toSeq)) ==
+            Some(Seq(Seq(column))), order.describe())
+        }
+
+        Seq[(Seq[SortOrder], String)](
+          (Seq(sort(FieldReference("missing"), SortDirection.ASCENDING)), 
"missing"),
+          (Seq(ChildrenOverridingSortOrder(FieldReference("missing"), 
Seq.empty)), "missing"),
+          (Seq(sort(FieldReference(Seq.empty[String]), 
SortDirection.ASCENDING)), "<empty>"),
+          (Seq(
+            sort(FieldReference("id"), SortDirection.ASCENDING),
+            sort(FieldReference("missing"), SortDirection.ASCENDING)), 
"missing")
+        ).foreach { case (ordering, columnsString) =>
+          reportingCatalog.reportedOrdering = ordering
+          val (_, reportingScan, warnings) = customReportingJoin()
+          assert(warnings.toSet ==
+            Set(ignoredReportWarning("ordering", "ordering", columnsString)), 
columnsString)
+          assert(reportingScan.ordering.isEmpty)
+          assert(reportingScan.keyGroupedPartitioning.isDefined)
+          val derivedOrdering = reportingScan.outputOrdering.map { o =>
+            (o.child.references.map(_.name).toSeq, o.direction)
+          }
+          assert(derivedOrdering == Seq((Seq("id"), Ascending)))
+        }
+      }
+    }
+  }
+
+  test("SPARK-59721: a reported partition key that cannot be converted still 
fails") {
+    val cases = Seq(
+      (plusOneKey("id"), "id + 1"),
+      (nestedKey("id"), "g(id)"))
+    withCustomReportingTable { reportingCatalog =>
+      cases.foreach { case (key, expr) =>
+        reportingCatalog.reportedKeys = Seq(key)
+        checkError(
+          exception = intercept[AnalysisException] {
+            customReportingJoinDf().collect()
+          },
+          condition = "_LEGACY_ERROR_TEMP_3054",
+          parameters = Map("expr" -> expr))
+      }
+    }
+  }
+
   test("SPARK-47094: SPJ: Support compatible buckets") {
     val table1 = "tab1e1"
     val table2 = "table2"
@@ -9761,3 +9922,75 @@ class KeyGroupedPartitioningCatalystRuntimeFilterSuite
     catalog.clearTables()
   }
 }
+
+/**
+ * An in-memory catalog whose scans report the partitioning and ordering set 
on the catalog instead
+ * of the table's own, so a test can report keys on columns the table does not 
have.
+ */
+class CustomReportingCatalog extends InMemoryTableCatalog {
+  var reportedKeys: Seq[Expression] = Seq.empty
+  var reportedOrdering: Seq[SortOrder] = Seq.empty
+
+  // scalastyle:off argcount
+  override protected def newInMemoryTable(
+      name: String,
+      columns: Array[Column],
+      partitioning: Array[Transform],
+      properties: java.util.Map[String, String],
+      constraints: Array[Constraint],
+      distribution: Distribution,
+      ordering: Array[SortOrder],
+      requiredNumPartitions: Option[Int],
+      advisoryPartitionSize: Option[Long],
+      distributionStrictlyRequired: Boolean,
+      numRowsPerSplit: Int,
+      id: String): InMemoryBaseTable = {
+    // scalastyle:on argcount
+    new InMemoryTable(name, columns, partitioning, properties, constraints, 
distribution,
+        ordering, requiredNumPartitions, advisoryPartitionSize, 
distributionStrictlyRequired,
+        numRowsPerSplit, id) {
+      override def newScanBuilder(options: CaseInsensitiveStringMap): 
ScanBuilder =
+        new InMemoryScanBuilder(schema(), options) {
+          override def build(): Scan =
+            CustomReportingScan(super.build(), reportedKeys, reportedOrdering)
+        }
+    }
+  }
+}
+
+case class CustomReportingScan(
+    inner: Scan,
+    keys: Seq[Expression],
+    ordering: Seq[SortOrder]) extends SupportsReportPartitioning with 
SupportsReportOrdering {
+  override def readSchema(): StructType = inner.readSchema()
+  override def toBatch: Batch = inner.toBatch
+  override def description(): String = inner.description()
+  override def outputPartitioning(): Partitioning = new KeyGroupedPartitioning(
+    keys.toArray,
+    
inner.asInstanceOf[SupportsReportPartitioning].outputPartitioning().numPartitions())
+  override def outputOrdering(): Array[SortOrder] = ordering.toArray
+}
+
+/**
+ * A connector-defined transform whose `children()` differs from its 
`arguments()`, which the
+ * default `Transform.children()` returns.
+ */
+case class ChildrenOverridingTransform(
+    name: String,
+    args: Seq[Expression],
+    childExprs: Seq[Expression]) extends Transform {
+  override def arguments(): Array[Expression] = args.toArray
+  override def children(): Array[Expression] = childExprs.toArray
+}
+
+/**
+ * A connector-defined sort order whose `children()` differs from 
`{expression()}`, which the
+ * default `SortOrder.children()` returns.
+ */
+case class ChildrenOverridingSortOrder(
+    expression: Expression,
+    childExprs: Seq[Expression]) extends SortOrder {
+  override def direction(): SortDirection = SortDirection.ASCENDING
+  override def nullOrdering(): NullOrdering = NullOrdering.NULLS_FIRST
+  override def children(): Array[Expression] = childExprs.toArray
+}
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 b8858ce33ce7..af50a1520e44 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
@@ -3027,10 +3027,12 @@ class MergeSubplansSuite extends PlanTest {
   }
 
   test("SPARK-40259: merge DSv2 scans that report empty key-grouped 
partitioning or ordering") {
-    // A source implementing SupportsReportPartitioning/SupportsReportOrdering 
but reporting nothing
-    // yields Some(Nil), not None (V2ScanPartitioningAndOrdering sets the 
field unconditionally). An
-    // empty report carries no partitioning/ordering to drop, so the merge 
should still proceed --
-    // the gate tests the inner Seq, not the Option. The fused plan is the 
plain column union.
+    // A source implementing SupportsReportOrdering but reporting no ordering 
yields Some(Nil), not
+    // None (V2ScanPartitioningAndOrdering sets the ordering unless a reported 
column cannot be
+    // resolved). The partitioning pass turns an empty report into None 
instead, so the rule never
+    // produces the keyGroupedPartitioning = Some(Nil) case below. An empty 
report carries no
+    // partitioning/ordering to drop, so the merge should still proceed -- the 
gate tests the inner
+    // Seq, not the Option. The fused plan is the plain column union.
     def assertMerges(withEmptyField: DataSourceV2ScanRelation => 
DataSourceV2ScanRelation): Unit = {
       val sub1 = 
ScalarSubquery(withEmptyField(v2ScanReading("a")).groupBy()(sum($"a").as("sum_a")))
       val sub2 = 
ScalarSubquery(withEmptyField(v2ScanReading("b")).groupBy()(sum($"b").as("sum_b")))


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

Reply via email to