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

MaxGekk 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 9448bf930689 [SPARK-57476][SQL] Extract BIN BY validation to lay 
groundwork for single-pass resolver
9448bf930689 is described below

commit 9448bf93068958a8d076d7e13fd79d0bc83ae45b
Author: Nikolina Vraneš <[email protected]>
AuthorDate: Fri Jun 26 16:14:04 2026 +0200

    [SPARK-57476][SQL] Extract BIN BY validation to lay groundwork for 
single-pass resolver
    
    ### What changes were proposed in this pull request?
    
    Extract the BIN BY validation and interval/origin folding out of the 
`ResolveBinBy` rule into a new neutral 
`BinByResolution.validateAndComputeParameters`, and have `ResolveBinBy` 
delegate to it (rule behavior unchanged).
    
    Two BIN BY error messages are also refined to match what the single-pass 
resolver produces, so the legacy and single-pass analyzers stay consistent:
    - `BIN_BY_REQUIRES_TOP_LEVEL_COLUMN` reports the full nested reference 
(e.g. `"outer.ts_start"`) instead of only the leaf identifier.
    - `UNRESOLVED_COLUMN.WITH_SUGGESTION` orders its suggestion list by 
similarity to the missing name instead of by output order.
    
    This PR does not add the single-pass resolver itself; that lands 
separately. `BinByResolution` is the shared helper it will reuse.
    
    ### Why are the changes needed?
    
    The validation is shared by the fixed-point `ResolveBinBy` and the upcoming 
single-pass `BinByResolver`; extracting it into a neutral object avoids 
duplicating the checks. Aligning the two error messages keeps both analyzers 
emitting the same message for the same query.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, to the two BIN BY error messages above. The operator is gated off by 
default via `spark.sql.binByRelationOperator.enabled`. For example, a nested 
range column now yields `"outer.ts_start"` rather than `` `ts_start` ``.
    
    ### How was this patch tested?
    
    Updated `ResolveBinBySuite` (nested-column expectation; new case asserting 
the similarity-ordered suggestion list) and `BinBySuite` (NTZ inputs plus a 
custom `ALIGN TO` with renamed outputs). Both pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56545 from vranes/bin-by-single-pass-analyzer.
    
    Authored-by: Nikolina Vraneš <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
---
 .../sql/catalyst/analysis/BinByResolution.scala    | 157 +++++++++++++++++++++
 .../spark/sql/catalyst/analysis/ResolveBinBy.scala | 118 +++-------------
 .../spark/sql/errors/QueryCompilationErrors.scala  |   4 +-
 .../sql/catalyst/analysis/ResolveBinBySuite.scala  |  12 +-
 .../scala/org/apache/spark/sql/BinBySuite.scala    |  25 ++++
 5 files changed, 218 insertions(+), 98 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
new file mode 100644
index 000000000000..890df8eb83fc
--- /dev/null
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/BinByResolution.scala
@@ -0,0 +1,157 @@
+/*
+ * 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.catalyst.analysis
+
+import scala.collection.mutable
+import scala.util.control.NonFatal
+
+import org.apache.spark.sql.catalyst.expressions.{Attribute, EmptyRow, 
Expression, ExprId}
+import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.{
+  AnyTimestampType,
+  DataType,
+  DayTimeIntervalType,
+  DoubleType,
+  FloatType,
+  TimestampType
+}
+
+/**
+ * Folded, validated BIN BY parameters produced by
+ * [[BinByResolution.validateAndComputeParameters]].
+ *
+ * @param binWidthMicros bin width in microseconds, folded from the BIN WIDTH 
interval
+ * @param originMicros ALIGN TO origin in microseconds; epoch (session-zone 
epoch for LTZ) when the
+ *   ALIGN TO clause is omitted
+ * @param rangeType data type of the RANGE columns (`TIMESTAMP` or 
`TIMESTAMP_NTZ`)
+ * @param timeZoneId session time zone, set iff `rangeType` is `TIMESTAMP` 
(LTZ)
+ */
+case class ResolvedBinByParameters(
+    binWidthMicros: Long,
+    originMicros: Long,
+    rangeType: DataType,
+    timeZoneId: Option[String])
+
+/**
+ * Shared BIN BY validation and folding for the [[ResolveBinBy]] rule and the 
single-pass
+ * `BinByResolver`: validates types/foldability and folds bin width and origin 
to microseconds.
+ */
+object BinByResolution {
+
+  /**
+   * Validates the BIN BY range, bin width, align-to, and distribute inputs 
and folds the bin width
+   * and origin to microseconds, returning the [[ResolvedBinByParameters]].
+   */
+  def validateAndComputeParameters(
+      rangeStart: Attribute,
+      rangeEnd: Attribute,
+      distributeAttributes: Seq[Attribute],
+      binWidthExpr: Expression,
+      originExpr: Option[Expression]): ResolvedBinByParameters = {
+    val rangeType = rangeStart.dataType
+
+    if (!AnyTimestampType.acceptsType(rangeType)) {
+      throw 
QueryCompilationErrors.binByRangeTypeMismatchError(rangeStart.name, rangeType)
+    }
+    if (rangeEnd.dataType != rangeType) {
+      throw QueryCompilationErrors.binByRangeTypeMismatchError(rangeEnd.name, 
rangeEnd.dataType)
+    }
+
+    if (!binWidthExpr.foldable) {
+      throw QueryCompilationErrors.binByNonFoldableInputError("BIN WIDTH", 
binWidthExpr)
+    }
+
+    val binWidthMicros: Long = binWidthExpr.dataType match {
+      case _: DayTimeIntervalType =>
+        val v = try {
+          binWidthExpr.eval(EmptyRow)
+        } catch {
+          case NonFatal(_) =>
+            throw 
QueryCompilationErrors.binByInvalidBinWidthError(binWidthExpr)
+        }
+        if (v == null) {
+          throw QueryCompilationErrors.binByNullArgumentError("BIN WIDTH")
+        }
+        if (v.asInstanceOf[Long] <= 0L) {
+          throw 
QueryCompilationErrors.binByNonPositiveBinWidthError(binWidthExpr)
+        }
+        v.asInstanceOf[Long]
+      case _ =>
+        throw 
QueryCompilationErrors.binByInvalidBinWidthTypeError(binWidthExpr)
+    }
+
+    val sessionZone = SQLConf.get.sessionLocalTimeZone
+    val isLTZ = rangeType.isInstanceOf[TimestampType]
+
+    // `ALIGN TO` is optional. When omitted, default the resolved plan's 
origin to
+    // `1970-01-01 00:00:00` in the session zone for `TIMESTAMP` (LTZ) and 
epoch for
+    // `TIMESTAMP_NTZ`.
+    val originMicros: Long = originExpr match {
+      case Some(o) =>
+        if (!o.foldable) {
+          throw QueryCompilationErrors.binByNonFoldableInputError("ALIGN TO", 
o)
+        }
+        if (o.dataType != rangeType) {
+          throw 
QueryCompilationErrors.binByAlignToTypeMismatchError(o.dataType, rangeType)
+        }
+        val v = try {
+          o.eval(EmptyRow)
+        } catch {
+          case NonFatal(_) =>
+            throw QueryCompilationErrors.binByInvalidAlignToError(o)
+        }
+
+        if (v == null) {
+          throw QueryCompilationErrors.binByNullArgumentError("ALIGN TO")
+        }
+
+        v.asInstanceOf[Long]
+      case None if isLTZ =>
+        DateTimeUtils.daysToMicros(0, DateTimeUtils.getZoneId(sessionZone))
+      case None =>
+        0L
+    }
+
+    if (distributeAttributes.isEmpty) {
+      throw QueryCompilationErrors.binByMissingDistributeError()
+    }
+
+    distributeAttributes.foreach { attr =>
+      attr.dataType match {
+        case _: FloatType | _: DoubleType =>
+        case other =>
+          throw 
QueryCompilationErrors.binByInvalidDistributeColumnTypeError(attr.name, other)
+      }
+    }
+    val seen = mutable.HashSet.empty[ExprId]
+    distributeAttributes.foreach { attr =>
+      if (!seen.add(attr.exprId)) {
+        throw 
QueryCompilationErrors.binByDuplicateDistributeColumnError(attr.name)
+      }
+    }
+
+    ResolvedBinByParameters(
+      binWidthMicros = binWidthMicros,
+      originMicros = originMicros,
+      rangeType = rangeType,
+      timeZoneId = if (isLTZ) Some(sessionZone) else None
+    )
+  }
+}
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
index 9018cf9a9be8..9cd225628d64 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBy.scala
@@ -17,18 +17,14 @@
 
 package org.apache.spark.sql.catalyst.analysis
 
-import scala.collection.mutable
-import scala.util.control.NonFatal
-
 import org.apache.spark.SparkException
-import org.apache.spark.sql.catalyst.expressions.{Attribute, EmptyRow, 
Expression, ExprId, NamedExpression}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, Expression}
 import org.apache.spark.sql.catalyst.plans.logical.{BinBy, LogicalPlan, 
UnresolvedBinBy}
 import org.apache.spark.sql.catalyst.rules.Rule
 import org.apache.spark.sql.catalyst.trees.TreePattern.UNRESOLVED_BIN_BY
-import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import org.apache.spark.sql.catalyst.util.StringUtils
 import org.apache.spark.sql.errors.QueryCompilationErrors
 import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.types.{AnyTimestampType, DayTimeIntervalType, 
DoubleType, FloatType, TimestampType}
 
 /**
  * Resolves [[UnresolvedBinBy]] into [[BinBy]]: looks up column references 
against the child's
@@ -59,98 +55,27 @@ object ResolveBinBy extends Rule[LogicalPlan] {
 
     val rangeStart = resolveColumn(b.rangeStartCol, child, resolver)
     val rangeEnd = resolveColumn(b.rangeEndCol, child, resolver)
-    val distributeAttrs = b.distributeColumns.map(c => resolveColumn(c, child, 
resolver))
-
-    val rangeType = rangeStart.dataType
-    if (!AnyTimestampType.acceptsType(rangeType)) {
-      throw 
QueryCompilationErrors.binByRangeTypeMismatchError(rangeStart.name, rangeType)
-    }
-    if (rangeEnd.dataType != rangeType) {
-      throw QueryCompilationErrors.binByRangeTypeMismatchError(rangeEnd.name, 
rangeEnd.dataType)
-    }
-
-    if (!b.binWidthExpr.foldable) {
-      throw QueryCompilationErrors.binByNonFoldableInputError("BIN WIDTH", 
b.binWidthExpr)
-    }
-    // Fold the bin width to micros.
-    val binWidthMicros: Long = b.binWidthExpr.dataType match {
-      case _: DayTimeIntervalType =>
-        val v = try {
-          b.binWidthExpr.eval(EmptyRow)
-        } catch {
-          case NonFatal(_) =>
-            throw 
QueryCompilationErrors.binByInvalidBinWidthError(b.binWidthExpr)
-        }
-        if (v == null) {
-          throw QueryCompilationErrors.binByNullArgumentError("BIN WIDTH")
-        }
-        if (v.asInstanceOf[Long] <= 0L) {
-          throw 
QueryCompilationErrors.binByNonPositiveBinWidthError(b.binWidthExpr)
-        }
-        v.asInstanceOf[Long]
-      case _ =>
-        throw 
QueryCompilationErrors.binByInvalidBinWidthTypeError(b.binWidthExpr)
-    }
-
-    val sessionZone = SQLConf.get.sessionLocalTimeZone
-    val isLTZ = rangeType.isInstanceOf[TimestampType]
-
-    // `ALIGN TO` is optional. When omitted, default the resolved plan's 
origin to
-    // `1970-01-01 00:00:00` in the session zone for `TIMESTAMP` (LTZ) and 
epoch for
-    // `TIMESTAMP_NTZ`.
-    val originMicros: Long = b.originExpr match {
-      case Some(o) =>
-        if (!o.foldable) {
-          throw QueryCompilationErrors.binByNonFoldableInputError("ALIGN TO", 
o)
-        }
-        if (o.dataType != rangeType) {
-          throw 
QueryCompilationErrors.binByAlignToTypeMismatchError(o.dataType, rangeType)
-        }
-        // Fold the origin to micros.
-        val v = try {
-          o.eval(EmptyRow)
-        } catch {
-          case NonFatal(_) =>
-            throw QueryCompilationErrors.binByInvalidAlignToError(o)
-        }
-        if (v == null) {
-          throw QueryCompilationErrors.binByNullArgumentError("ALIGN TO")
-        }
-        v.asInstanceOf[Long]
-      case None if isLTZ =>
-        DateTimeUtils.daysToMicros(0, DateTimeUtils.getZoneId(sessionZone))
-      case None =>
-        0L
-    }
+    val distributeAttributes = b.distributeColumns.map(c => resolveColumn(c, 
child, resolver))
 
-    if (distributeAttrs.isEmpty) {
-      throw QueryCompilationErrors.binByMissingDistributeError()
-    }
-    distributeAttrs.foreach { attr =>
-      attr.dataType match {
-        case _: FloatType | _: DoubleType => // ok
-        case other =>
-          throw 
QueryCompilationErrors.binByInvalidDistributeColumnTypeError(attr.name, other)
-      }
-    }
-    val seen = mutable.HashSet.empty[ExprId]
-    distributeAttrs.foreach { attr =>
-      if (!seen.add(attr.exprId)) {
-        throw 
QueryCompilationErrors.binByDuplicateDistributeColumnError(attr.name)
-      }
-    }
+    val parameters = BinByResolution.validateAndComputeParameters(
+      rangeStart = rangeStart,
+      rangeEnd = rangeEnd,
+      distributeAttributes = distributeAttributes,
+      binWidthExpr = b.binWidthExpr,
+      originExpr = b.originExpr)
 
-    val appendedAttributes = BinBy.appendedAttributesWithAliases(rangeType, 
b.outputAliases)
+    val appendedAttributes =
+      BinBy.appendedAttributesWithAliases(parameters.rangeType, 
b.outputAliases)
 
     BinBy(
-      binWidthMicros = binWidthMicros,
+      binWidthMicros = parameters.binWidthMicros,
       rangeStart = rangeStart,
       rangeEnd = rangeEnd,
-      originMicros = originMicros,
-      distributeColumns = distributeAttrs,
+      originMicros = parameters.originMicros,
+      distributeColumns = distributeAttributes,
       appendedAttributes = appendedAttributes,
       child = child,
-      timeZoneId = if (isLTZ) Some(sessionZone) else None)
+      timeZoneId = parameters.timeZoneId)
   }
 
   private def resolveColumn(
@@ -162,13 +87,16 @@ object ResolveBinBy extends Rule[LogicalPlan] {
       child.resolve(u.nameParts, resolver) match {
         case Some(a: Attribute) => a
         case _ =>
-          throw QueryCompilationErrors.unresolvedColumnError(u.name, 
child.output.map(_.name))
+          throw QueryCompilationErrors.unresolvedColumnError(
+            u.name,
+            proposal = StringUtils.orderSuggestedIdentifiersBySimilarity(
+              u.name,
+              candidates = child.output.map(attribute => attribute.qualifier 
:+ attribute.name)))
       }
     case a: Attribute => a
-    case ne: NamedExpression if ne.resolved =>
-      // A resolved non-Attribute here is a nested field (e.g. `struct.field`) 
that
-      // ResolveReferences wrapped in an Alias; BIN BY only accepts top-level 
columns.
-      throw QueryCompilationErrors.binByRequiresTopLevelColumnError(ne.name)
+    case alias: Alias if alias.resolved =>
+      // A resolved nested ref (e.g. `struct.field`) exists but isn't a 
top-level column.
+      throw 
QueryCompilationErrors.binByRequiresTopLevelColumnError(alias.child)
     case other =>
       // Genuinely unexpected; the grammar restricts these positions to 
multipartIdentifier.
       throw SparkException.internalError(
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index 25b750fa319e..f26c32a07059 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -313,10 +313,10 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
       messageParameters = Map.empty)
   }
 
-  def binByRequiresTopLevelColumnError(columnName: String): Throwable = {
+  def binByRequiresTopLevelColumnError(reference: Expression): Throwable = {
     new AnalysisException(
       errorClass = "BIN_BY_REQUIRES_TOP_LEVEL_COLUMN",
-      messageParameters = Map("columnName" -> toSQLId(columnName)))
+      messageParameters = Map("columnName" -> toSQLExpr(reference)))
   }
 
   def binByInvalidDistributeColumnTypeError(
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
index 2fbf8aee8dc4..65f093a72171 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/ResolveBinBySuite.scala
@@ -249,6 +249,16 @@ class ResolveBinBySuite extends AnalysisTest {
       "UNRESOLVED_COLUMN.WITH_SUGGESTION")
   }
 
+  test("UNRESOLVED_COLUMN suggestions are ordered by similarity to the missing 
name") {
+    // `valeu` is one edit away from `value`, so `value` is suggested first 
and the remaining
+    // columns follow by edit distance.
+    val ex = intercept[SparkThrowable](
+      ResolveBinBy.apply(unresolved(rangeStart = 
UnresolvedAttribute("valeu"))))
+    assert(ex.getCondition == "UNRESOLVED_COLUMN.WITH_SUGGESTION")
+    assert(ex.getMessageParameters.get("objectName") == "`valeu`")
+    assert(ex.getMessageParameters.get("proposal") == "`value`, `label`, 
`ts_end`, `ts_start`")
+  }
+
   test("rejects nested column refs through the full analyzer 
(RULE_ORDERING_DEPENDENCIES)") {
     val structCol = AttributeReference(
       "outer",
@@ -268,7 +278,7 @@ class ResolveBinBySuite extends AnalysisTest {
     assertAnalysisErrorCondition(
       plan,
       "BIN_BY_REQUIRES_TOP_LEVEL_COLUMN",
-      Map("columnName" -> "`ts_start`"))
+      Map("columnName" -> "\"outer.ts_start\""))
   }
 
   test("rejects non-timestamp or mismatched RANGE columns") {
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
index 8ec0a1a44a8c..a00649c1f1cd 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
@@ -71,4 +71,29 @@ class BinBySuite extends QueryTest with SharedSparkSession {
         parameters = Map.empty[String, String])
     }
   }
+
+  test("BIN BY analyzes NTZ inputs and a custom ALIGN TO with renamed 
outputs") {
+    withSQLConf(SQLConf.BIN_BY_ENABLED.key -> "true") {
+      // NTZ inputs default the origin to epoch (LTZ defaults to the 
session-zone epoch).
+      spark.sql(
+        """SELECT * FROM VALUES
+          |  (TIMESTAMP_NTZ'2024-01-01 00:00:00', TIMESTAMP_NTZ'2024-01-01 
01:00:00', 1.0D)
+          |  AS t(ts_start, ts_end, value)
+          |BIN BY (RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  DISTRIBUTE UNIFORM (value))
+          |""".stripMargin).queryExecution.assertAnalyzed()
+
+      // Custom ALIGN TO origin with renamed output columns.
+      spark.sql(
+        """SELECT * FROM VALUES
+          |  (TIMESTAMP'2024-01-01 00:00:00', TIMESTAMP'2024-01-01 02:00:00', 
10.0D, 5.0D)
+          |  AS t(ts_start, ts_end, a, b)
+          |BIN BY (RANGE ts_start TO ts_end BIN WIDTH INTERVAL '1' HOUR
+          |  ALIGN TO TIMESTAMP'2024-01-01 00:30:00'
+          |  DISTRIBUTE UNIFORM (a, b)
+          |  BIN_START AS w_start BIN_END AS w_end
+          |  BIN_DISTRIBUTE_RATIO AS frac)
+          |""".stripMargin).queryExecution.assertAnalyzed()
+    }
+  }
 }


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

Reply via email to