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

cloud-fan 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 99f574b0b4bd [SPARK-58003][SQL] Add physical execution for BIN BY
99f574b0b4bd is described below

commit 99f574b0b4bdca6c6a80424b956838dc97ff2127
Author: Nikolina Vraneš <[email protected]>
AuthorDate: Wed Jul 15 10:53:57 2026 +0800

    [SPARK-58003][SQL] Add physical execution for BIN BY
    
    ### What changes were proposed in this pull request?
    
    This PR makes the `BIN BY` relation operator execute end-to-end. It adds 
the `BinByExec` physical operator and wires it into planning, replacing the 
stub that raised `UNSUPPORTED_FEATURE.BIN_BY`.
    
    For each input row, `BinByExec` emits one row per bin that overlaps the 
row's `[range_start, range_end)`:
    - `DISTRIBUTE UNIFORM` columns are rescaled by the bin's overlap fraction.
    - All other columns, including the range columns, are copied unchanged.
    - `bin_start`, `bin_end`, and `bin_distribute_ratio` are appended.
    
    Bin boundaries reuse `DateTimeUtils.timeBucketDTInterval` / 
`timestampAddDayTime`, matching `time_bucket`: sub-day widths use UTC 
microsecond arithmetic; multi-day widths use civil-time arithmetic in the 
session zone (UTC for `TIMESTAMP_NTZ`).
    
    Per-row edge cases:
    - Zero-length range (`range_start == range_end`): one row with ratio `1.0`.
    - Inverted range (`range_start > range_end`): raises `BIN_BY_INVALID_RANGE`.
    - NULL in either range column: one row with the scaled DISTRIBUTE values 
and all three appended columns NULL (no valid bin to scale into); passthrough 
columns are unaffected.
    
    ### Why are the changes needed?
    
    `BIN BY` parsed, resolved, and type-checked, but planning threw 
`UNSUPPORTED_FEATURE.BIN_BY` because it had no physical execution. This PR adds 
that execution so `BIN BY` queries can run.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. `BIN BY` is gated off by default 
(`spark.sql.binByRelationOperator.enabled`).
    
    ### How was this patch tested?
    
    - `BinBySuite`: end-to-end execution covering proportional splits, 
single-bin passthrough, FLOAT + DOUBLE scaling, downstream `GROUP BY`, 
zero-length and NULL ranges, the inverted-range error, nested-struct 
passthrough, renamed outputs, and civil-time boundaries across a DST transition 
(LTZ and NTZ).
    - `bin-by.sql` golden file: executing scenarios plus analysis-error cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Anthropic)
    
    Closes #57086 from vranes/bin-by-physical-exec.
    
    Authored-by: Nikolina Vraneš <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../sql/catalyst/analysis/BinByResolution.scala    |   7 +-
 .../spark/sql/errors/QueryExecutionErrors.scala    |   7 +
 .../org/apache/spark/sql/execution/BinByExec.scala | 193 +++++++++++++++++
 .../spark/sql/execution/SparkStrategies.scala      |  14 +-
 .../sql-tests/analyzer-results/bin-by.sql.out      | 122 +++++++++++
 .../src/test/resources/sql-tests/inputs/bin-by.sql |  58 +++++
 .../resources/sql-tests/results/bin-by.sql.out     | 121 +++++++++++
 .../scala/org/apache/spark/sql/BinBySuite.scala    | 236 ++++++++++++++++++---
 8 files changed, 718 insertions(+), 40 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
index d7dad79b07f6..ec8afaefc52c 100644
--- 
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
@@ -168,9 +168,10 @@ object BinByResolution {
     AttributeReference(aliases.effectiveBinRatio, DoubleType, nullable = 
true)())
 
   /**
-   * Mints a produced output attribute for each DISTRIBUTE input column: same 
name, type, and
-   * nullability, but a fresh `ExprId` so the rescaled value is a distinct 
attribute from the input.
+   * Mints a produced output attribute for each DISTRIBUTE input column: same 
name, type, and a
+   * fresh `ExprId` so the rescaled value is a distinct attribute from the 
input. Always nullable:
+   * a NULL range column produces a NULL scaled value regardless of the input 
column's nullability.
    */
   def scaledDistributeAttributes(distributeColumns: Seq[Attribute]): 
Seq[Attribute] =
-    distributeColumns.map(a => AttributeReference(a.name, a.dataType, 
a.nullable)())
+    distributeColumns.map(a => AttributeReference(a.name, a.dataType, nullable 
= true)())
 }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
index b53267e8583f..ef7d3a1317a8 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryExecutionErrors.scala
@@ -3481,4 +3481,11 @@ private[sql] object QueryExecutionErrors extends 
QueryErrorsBase with ExecutionE
       )
     )
   }
+
+  def binByInvalidRangeError(rangeStart: String, rangeEnd: String): Throwable 
= {
+    new SparkRuntimeException(
+      errorClass = "BIN_BY_INVALID_RANGE",
+      messageParameters = Map("rangeStart" -> rangeStart, "rangeEnd" -> 
rangeEnd)
+    )
+  }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
new file mode 100644
index 000000000000..d95addd93f40
--- /dev/null
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
@@ -0,0 +1,193 @@
+/*
+ * 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
+
+import java.time.ZoneOffset
+
+import org.apache.spark.SparkException
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeMap, 
AttributeSet, BindReferences, BoundReference, Cast, Expression, 
GenericInternalRow, JoinedRow, Literal, Multiply, UnsafeProjection}
+import org.apache.spark.sql.catalyst.util.{DateTimeUtils, TimestampFormatter}
+import org.apache.spark.sql.errors.QueryExecutionErrors
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.sql.types.DoubleType
+
+/**
+ * Physical node for the `BIN BY` relation operator. For each input row it 
emits one output row per
+ * bin overlapping `[rangeStart, rangeEnd)`, scaling the DISTRIBUTE UNIFORM 
columns by the overlap
+ * fraction and appending `bin_start`, `bin_end`, `bin_distribute_ratio`.
+ *
+ * Bin boundaries reuse [[DateTimeUtils.timeBucketDTInterval]] /
+ * [[DateTimeUtils.timestampAddDayTime]], matching `time_bucket`: sub-day 
widths use UTC microsecond
+ * arithmetic, multi-day widths use civil-time arithmetic in the session zone 
(UTC for
+ * TIMESTAMP_NTZ). DISTRIBUTE UNIFORM columns are FLOAT or DOUBLE only and 
scale by multiplication.
+ */
+case class BinByExec(
+    binWidthMicros: Long,
+    originMicros: Long,
+    rangeStart: Attribute,
+    rangeEnd: Attribute,
+    distributeColumns: Seq[Attribute],
+    scaledDistributeColumns: Seq[Attribute],
+    appendedAttributes: Seq[Attribute],
+    timeZoneId: Option[String],
+    child: SparkPlan)
+  extends UnaryExecNode {
+
+  override lazy val metrics: Map[String, SQLMetric] = Map(
+    "numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of output 
rows"))
+
+  private val distributeReplacements: AttributeMap[Attribute] =
+    AttributeMap(distributeColumns.zip(scaledDistributeColumns))
+
+  private val numAppended: Int = appendedAttributes.length
+  assert(numAppended == 3,
+    s"BinBy appends exactly 3 columns (bin_start, bin_end, 
bin_distribute_ratio), got $numAppended")
+
+  override def output: Seq[Attribute] =
+    child.output.map(a => distributeReplacements.getOrElse(a, a)) ++ 
appendedAttributes
+
+  override def producedAttributes: AttributeSet =
+    AttributeSet(scaledDistributeColumns ++ appendedAttributes)
+
+  override protected def withNewChildInternal(newChild: SparkPlan): BinByExec =
+    copy(child = newChild)
+
+  protected override def doExecute(): RDD[InternalRow] = {
+    val width = binWidthMicros
+    val origin = originMicros
+    val zone = 
timeZoneId.map(DateTimeUtils.getZoneId).getOrElse(ZoneOffset.UTC)
+    val numOutputRows = longMetric("numOutputRows")
+
+    val rsIdx = bindOrdinal(rangeStart)
+    val reIdx = bindOrdinal(rangeEnd)
+    val childLen = child.output.length
+
+    // Bound over JoinedRow(childRow, appendedRow); reused for every sub-row.
+    val projExprs = buildOutputExpressions(childLen)
+    // Bound over the child row alone, for the null-range path.
+    val nullProjExprs = buildNullRangeExpressions()
+
+    child.execute().mapPartitionsInternal { rows =>
+      val proj = UnsafeProjection.create(projExprs)
+      val nullProj = UnsafeProjection.create(nullProjExprs)
+      val appended = new GenericInternalRow(numAppended)
+      val joined = new JoinedRow
+      val fmt = TimestampFormatter.getFractionFormatter(zone)
+
+      rows.flatMap { row =>
+        joined.withLeft(row)
+        val rowIter: Iterator[InternalRow] =
+          if (row.isNullAt(rsIdx) || row.isNullAt(reIdx)) {
+            Iterator.single(nullProj(row))
+          } else {
+            val rs = row.getLong(rsIdx)
+            val re = row.getLong(reIdx)
+            if (rs > re) {
+              throw 
QueryExecutionErrors.binByInvalidRangeError(fmt.format(rs), fmt.format(re))
+            } else if (rs == re) {
+              val binStart = DateTimeUtils.timeBucketDTInterval(width, rs, 
origin, zone)
+              val binEnd = DateTimeUtils.timestampAddDayTime(binStart, width, 
zone)
+              appended.update(0, binStart)
+              appended.update(1, binEnd)
+              appended.update(2, 1.0d)
+              Iterator.single(proj(joined.withRight(appended)))
+            } else {
+              val total = Math.subtractExact(re, rs)
+              new Iterator[InternalRow] {
+                private var curStart =
+                  DateTimeUtils.timeBucketDTInterval(width, rs, origin, zone)
+
+                override def hasNext: Boolean = curStart < re
+
+                override def next(): InternalRow = {
+                  val curEnd = DateTimeUtils.timestampAddDayTime(curStart, 
width, zone)
+                  val overlap = math.min(re, curEnd) - math.max(rs, curStart)
+                  appended.update(0, curStart)
+                  appended.update(1, curEnd)
+                  appended.update(2, overlap.toDouble / total.toDouble)
+                  curStart = curEnd
+                  proj(joined.withRight(appended))
+                }
+              }
+            }
+          }
+        rowIter.map { r =>
+          numOutputRows += 1
+          r
+        }
+      }
+    }
+  }
+
+  private def bindOrdinal(a: Attribute): Int =
+    (BindReferences.bindReference(a, child.output): Expression) match {
+      case b: BoundReference => b.ordinal
+      case _ =>
+        throw SparkException.internalError(
+          s"BinByExec attribute ${a.name}#${a.exprId.id} is not bound in child 
output " +
+            s"${child.output.mkString("[", ", ", "]")}")
+    }
+
+  /**
+   * Output projection over `JoinedRow(childRow, appendedRow)`: DISTRIBUTE 
columns scaled by the
+   * ratio, other columns passed through.
+   */
+  private def buildOutputExpressions(childLen: Int): Seq[Expression] = {
+    val distSet = distributeColumns.map(bindOrdinal).toSet
+    // bin_distribute_ratio is the last appended column.
+    val ratioRef = BoundReference(childLen + numAppended - 1, DoubleType, 
nullable = false)
+
+    val forwarded = child.output.zipWithIndex.map { case (attr, ordinal) =>
+      val ref = BoundReference(ordinal, attr.dataType, attr.nullable)
+      if (distSet.contains(ordinal)) {
+        // Cast to double, multiply, narrow back. These expressions bind at 
execution time, so the
+        // analyzer's operand coercion never runs; a bare Multiply(FloatType, 
DoubleType) fails.
+        Cast(Multiply(Cast(ref, DoubleType), ratioRef), attr.dataType)
+      } else {
+        ref
+      }
+    }
+
+    val appendedRefs = appendedAttributes.zipWithIndex.map { case (attr, i) =>
+      BoundReference(childLen + i, attr.dataType, attr.nullable)
+    }
+
+    forwarded ++ appendedRefs
+  }
+
+  /**
+   * Null-range projection over the child row: DISTRIBUTE and appended columns 
are NULL (no valid
+   * bin), other columns passed through.
+   */
+  private def buildNullRangeExpressions(): Seq[Expression] = {
+    val distSet = distributeColumns.map(bindOrdinal).toSet
+    val forwarded = child.output.zipWithIndex.map { case (attr, ordinal) =>
+      if (distSet.contains(ordinal)) {
+        Literal.create(null, attr.dataType)
+      } else {
+        BoundReference(ordinal, attr.dataType, attr.nullable)
+      }
+    }
+    val nullAppended = appendedAttributes.map { attr =>
+      Literal.create(null, attr.dataType)
+    }
+    forwarded ++ nullAppended
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index e8d29dfa7c5e..752ba33bdad2 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -1043,9 +1043,6 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
         throw SparkException.internalError(
           "Deduplicate operator for non streaming data source should have been 
replaced " +
             "by aggregate in the optimizer")
-      case _: logical.BinBy =>
-        throw new 
SparkUnsupportedOperationException("UNSUPPORTED_FEATURE.BIN_BY")
-
       case logical.DeserializeToObject(deserializer, objAttr, child) =>
         execution.DeserializeToObjectExec(deserializer, objAttr, 
planLater(child)) :: Nil
       case logical.SerializeFromObject(serializer, child) =>
@@ -1234,6 +1231,17 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
           staticPartitions) :: Nil
       case MultiResult(children) =>
         MultiResultExec(children.map(planLater)) :: Nil
+      case b: logical.BinBy =>
+        execution.BinByExec(
+          binWidthMicros = b.binWidthMicros,
+          originMicros = b.originMicros,
+          rangeStart = b.rangeStart,
+          rangeEnd = b.rangeEnd,
+          distributeColumns = b.distributeColumns,
+          scaledDistributeColumns = b.scaledDistributeColumns,
+          appendedAttributes = b.appendedAttributes,
+          timeZoneId = b.timeZoneId,
+          child = planLater(b.child)) :: Nil
       case _ => Nil
     }
   }
diff --git 
a/sql/core/src/test/resources/sql-tests/analyzer-results/bin-by.sql.out 
b/sql/core/src/test/resources/sql-tests/analyzer-results/bin-by.sql.out
new file mode 100644
index 000000000000..91f951d8a403
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/analyzer-results/bin-by.sql.out
@@ -0,0 +1,122 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SET spark.sql.binByRelationOperator.enabled = true
+-- !query analysis
+SetCommand (spark.sql.binByRelationOperator.enabled,Some(true))
+
+
+-- !query
+SET TIME ZONE 'UTC'
+-- !query analysis
+SetCommand (spark.sql.session.timeZone,Some(UTC))
+
+
+-- !query
+CREATE OR REPLACE TEMP VIEW metrics AS
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:05:00', 100.0D),
+  (TIMESTAMP '2024-01-01 00:02:00', TIMESTAMP '2024-01-01 00:12:00', 300.0D)
+AS metrics(ts_start, ts_end, value)
+-- !query analysis
+CreateViewCommand `metrics`, SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:05:00', 100.0D),
+  (TIMESTAMP '2024-01-01 00:02:00', TIMESTAMP '2024-01-01 00:12:00', 300.0D)
+AS metrics(ts_start, ts_end, value), false, true, LocalTempView, UNSUPPORTED, 
true
+   +- Project [ts_start#x, ts_end#x, value#x]
+      +- SubqueryAlias metrics
+         +- LocalRelation [ts_start#x, ts_end#x, value#x]
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+)
+-- !query analysis
+Project [ts_start#x, ts_end#x, value#x, bin_start#x, bin_end#x, 
bin_distribute_ratio#x]
++- BinBy 300000000, ts_start#x: timestamp, ts_end#x: timestamp, 
1704067200000000, [value#x], [value#x], [bin_start#x, bin_end#x, 
bin_distribute_ratio#x], UTC
+   +- SubqueryAlias metrics
+      +- View (`metrics`, [ts_start#x, ts_end#x, value#x])
+         +- Project [cast(ts_start#x as timestamp) AS ts_start#x, 
cast(ts_end#x as timestamp) AS ts_end#x, cast(value#x as double) AS value#x]
+            +- Project [ts_start#x, ts_end#x, value#x]
+               +- SubqueryAlias metrics
+                  +- LocalRelation [ts_start#x, ts_end#x, value#x]
+
+
+-- !query
+SELECT bin_start, SUM(value) AS total
+FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+)
+GROUP BY bin_start
+ORDER BY bin_start
+-- !query analysis
+Sort [bin_start#x ASC NULLS FIRST], true
++- Aggregate [bin_start#x], [bin_start#x, sum(value#x) AS total#x]
+   +- BinBy 300000000, ts_start#x: timestamp, ts_end#x: timestamp, 
1704067200000000, [value#x], [value#x], [bin_start#x, bin_end#x, 
bin_distribute_ratio#x], UTC
+      +- SubqueryAlias metrics
+         +- View (`metrics`, [ts_start#x, ts_end#x, value#x])
+            +- Project [cast(ts_start#x as timestamp) AS ts_start#x, 
cast(ts_end#x as timestamp) AS ts_end#x, cast(value#x as double) AS value#x]
+               +- Project [ts_start#x, ts_end#x, value#x]
+                  +- SubqueryAlias metrics
+                     +- LocalRelation [ts_start#x, ts_end#x, value#x]
+
+
+-- !query
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:08:00', 100.0D)
+  AS t(ts_start, ts_end, value)
+BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:01:00'
+  DISTRIBUTE UNIFORM (value)
+  BIN_START AS window_start
+  BIN_END AS window_end
+  BIN_DISTRIBUTE_RATIO AS frac
+)
+-- !query analysis
+Project [ts_start#x, ts_end#x, value#x, window_start#x, window_end#x, frac#x]
++- BinBy 300000000, ts_start#x: timestamp, ts_end#x: timestamp, 
1704067260000000, [value#x], [value#x], [window_start#x, window_end#x, frac#x], 
UTC
+   +- SubqueryAlias t
+      +- LocalRelation [ts_start#x, ts_end#x, value#x]
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value, value)
+)
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+  "errorClass" : "BIN_BY_DUPLICATE_DISTRIBUTE_COLUMN",
+  "sqlState" : "42713",
+  "messageParameters" : {
+    "columnName" : "`value`"
+  }
+}
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE value TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value)
+)
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+  "errorClass" : "BIN_BY_RANGE_TYPE_MISMATCH",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "columnName" : "`value`",
+    "columnType" : "\"DOUBLE\""
+  }
+}
diff --git a/sql/core/src/test/resources/sql-tests/inputs/bin-by.sql 
b/sql/core/src/test/resources/sql-tests/inputs/bin-by.sql
new file mode 100644
index 000000000000..5413a148ef77
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/inputs/bin-by.sql
@@ -0,0 +1,58 @@
+SET spark.sql.binByRelationOperator.enabled = true;
+SET TIME ZONE 'UTC';
+
+CREATE OR REPLACE TEMP VIEW metrics AS
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:05:00', 100.0D),
+  (TIMESTAMP '2024-01-01 00:02:00', TIMESTAMP '2024-01-01 00:12:00', 300.0D)
+AS metrics(ts_start, ts_end, value);
+
+
+-- Basic split: one row fits a single bin, the other spans multiple bins.
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+);
+
+
+-- Composability: feed BIN BY into a downstream GROUP BY.
+SELECT bin_start, SUM(value) AS total
+FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+)
+GROUP BY bin_start
+ORDER BY bin_start;
+
+
+-- Custom ALIGN TO origin and renamed output columns.
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:08:00', 100.0D)
+  AS t(ts_start, ts_end, value)
+BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:01:00'
+  DISTRIBUTE UNIFORM (value)
+  BIN_START AS window_start
+  BIN_END AS window_end
+  BIN_DISTRIBUTE_RATIO AS frac
+);
+
+
+-- Analysis errors: duplicate DISTRIBUTE column and a non-timestamp RANGE 
column.
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value, value)
+);
+
+SELECT * FROM metrics BIN BY (
+  RANGE value TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value)
+);
diff --git a/sql/core/src/test/resources/sql-tests/results/bin-by.sql.out 
b/sql/core/src/test/resources/sql-tests/results/bin-by.sql.out
new file mode 100644
index 000000000000..7630f54276a2
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/results/bin-by.sql.out
@@ -0,0 +1,121 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SET spark.sql.binByRelationOperator.enabled = true
+-- !query schema
+struct<key:string,value:string>
+-- !query output
+spark.sql.binByRelationOperator.enabled        true
+
+
+-- !query
+SET TIME ZONE 'UTC'
+-- !query schema
+struct<key:string,value:string>
+-- !query output
+spark.sql.session.timeZone     UTC
+
+
+-- !query
+CREATE OR REPLACE TEMP VIEW metrics AS
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:05:00', 100.0D),
+  (TIMESTAMP '2024-01-01 00:02:00', TIMESTAMP '2024-01-01 00:12:00', 300.0D)
+AS metrics(ts_start, ts_end, value)
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+)
+-- !query schema
+struct<ts_start:timestamp,ts_end:timestamp,value:double,bin_start:timestamp,bin_end:timestamp,bin_distribute_ratio:double>
+-- !query output
+2024-01-01 00:00:00    2024-01-01 00:05:00     100.0   2024-01-01 00:00:00     
2024-01-01 00:05:00     1.0
+2024-01-01 00:02:00    2024-01-01 00:12:00     150.0   2024-01-01 00:05:00     
2024-01-01 00:10:00     0.5
+2024-01-01 00:02:00    2024-01-01 00:12:00     60.0    2024-01-01 00:10:00     
2024-01-01 00:15:00     0.2
+2024-01-01 00:02:00    2024-01-01 00:12:00     90.0    2024-01-01 00:00:00     
2024-01-01 00:05:00     0.3
+
+
+-- !query
+SELECT bin_start, SUM(value) AS total
+FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:00:00'
+  DISTRIBUTE UNIFORM (value)
+)
+GROUP BY bin_start
+ORDER BY bin_start
+-- !query schema
+struct<bin_start:timestamp,total:double>
+-- !query output
+2024-01-01 00:00:00    190.0
+2024-01-01 00:05:00    150.0
+2024-01-01 00:10:00    60.0
+
+
+-- !query
+SELECT * FROM VALUES
+  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:08:00', 100.0D)
+  AS t(ts_start, ts_end, value)
+BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  ALIGN TO TIMESTAMP '2024-01-01 00:01:00'
+  DISTRIBUTE UNIFORM (value)
+  BIN_START AS window_start
+  BIN_END AS window_end
+  BIN_DISTRIBUTE_RATIO AS frac
+)
+-- !query schema
+struct<ts_start:timestamp,ts_end:timestamp,value:double,window_start:timestamp,window_end:timestamp,frac:double>
+-- !query output
+2024-01-01 00:00:00    2024-01-01 00:08:00     12.5    2023-12-31 23:56:00     
2024-01-01 00:01:00     0.125
+2024-01-01 00:00:00    2024-01-01 00:08:00     25.0    2024-01-01 00:06:00     
2024-01-01 00:11:00     0.25
+2024-01-01 00:00:00    2024-01-01 00:08:00     62.5    2024-01-01 00:01:00     
2024-01-01 00:06:00     0.625
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE ts_start TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value, value)
+)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+  "errorClass" : "BIN_BY_DUPLICATE_DISTRIBUTE_COLUMN",
+  "sqlState" : "42713",
+  "messageParameters" : {
+    "columnName" : "`value`"
+  }
+}
+
+
+-- !query
+SELECT * FROM metrics BIN BY (
+  RANGE value TO ts_end
+  BIN WIDTH INTERVAL '5' MINUTE
+  DISTRIBUTE UNIFORM (value)
+)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+  "errorClass" : "BIN_BY_RANGE_TYPE_MISMATCH",
+  "sqlState" : "42K09",
+  "messageParameters" : {
+    "columnName" : "`value`",
+    "columnType" : "\"DOUBLE\""
+  }
+}
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 2004aa89aa06..3e00a0645e0d 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
@@ -17,12 +17,23 @@
 
 package org.apache.spark.sql
 
-import org.apache.spark.{SparkThrowable, SparkUnsupportedOperationException}
+import java.sql.Timestamp
+import java.time.{LocalDateTime, ZoneId, ZoneOffset}
+
+import org.apache.spark.SparkThrowable
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 
 class BinBySuite extends QueryTest with SharedSparkSession {
 
+  private def tsAt(s: String, zone: ZoneId = ZoneOffset.UTC): Timestamp =
+    Timestamp.from(LocalDateTime.parse(s.replace(' ', 
'T')).atZone(zone).toInstant)
+
+  private def ntz(s: String): LocalDateTime = LocalDateTime.parse(s.replace(' 
', 'T'))
+
+  private def ratio(overlapMicros: Long, totalMicros: Long): Double =
+    overlapMicros.toDouble / totalMicros.toDouble
+
   private def createMetricsView(): Unit = {
     spark.sql(
       """SELECT TIMESTAMP '2024-01-01 00:00:00' AS ts_start,
@@ -37,24 +48,169 @@ class BinBySuite extends QueryTest with SharedSparkSession 
{
       |  DISTRIBUTE UNIFORM (value)
       |)""".stripMargin
 
-  test("BIN BY analyzes but physical execution is not yet implemented") {
-    withSQLConf(SQLConf.BIN_BY_ENABLED.key -> "true") {
-      withTempView("metrics") {
-        createMetricsView()
-        val df = spark.sql(binByQuery)
+  test("BIN BY splits a range into proportional sub-rows") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 
00:10:00', 100.0D)
+          |  AS metrics(ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))
+          |ORDER BY bin_start""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(tsAt("2024-01-01 00:00:00"), tsAt("2024-01-01 00:05:00"), 0.5, 
50.0),
+        Row(tsAt("2024-01-01 00:05:00"), tsAt("2024-01-01 00:10:00"), 0.5, 
50.0)))
+    }
+  }
 
-        // Analysis is fully functional when the operator is enabled.
-        df.queryExecution.assertAnalyzed()
+  test("BIN BY scales FLOAT and DOUBLE DISTRIBUTE columns in one clause") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT bin_start, f, d
+          |FROM VALUES
+          |  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 00:10:00',
+          |   CAST(100.0 AS FLOAT), 200.0D)
+          |  AS metrics(ts_start, ts_end, f, d)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM (f, 
d))
+          |ORDER BY bin_start""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(tsAt("2024-01-01 00:00:00"), 50.0f, 100.0),
+        Row(tsAt("2024-01-01 00:05:00"), 50.0f, 100.0)))
+    }
+  }
 
-        // Physical execution is stubbed until the follow-up PR; planning 
surfaces a clean
-        // UNSUPPORTED_FEATURE error rather than an internal error.
-        checkError(
-          exception = intercept[SparkUnsupportedOperationException] {
-            df.collect()
-          },
-          condition = "UNSUPPORTED_FEATURE.BIN_BY",
-          parameters = Map.empty[String, String])
-      }
+  test("BIN BY passes a single-bin range through with ratio 1.0") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 
00:05:00', 100.0D)
+          |  AS metrics(ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(tsAt("2024-01-01 00:00:00"), tsAt("2024-01-01 00:05:00"), 1.0, 
100.0)))
+    }
+  }
+
+  test("BIN BY emits a NULL-range row with all computed columns NULL") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT id, bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (1, CAST(NULL AS TIMESTAMP), TIMESTAMP '2024-01-01 00:10:00', 
100.0D),
+          |  (2, TIMESTAMP '2024-01-01 00:00:00', CAST(NULL AS TIMESTAMP), 
200.0D)
+          |  AS metrics(id, ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))
+          |ORDER BY id""".stripMargin)
+      // A NULL range nulls every computed column; only the `id` passthrough 
survives.
+      checkAnswer(df, Seq(
+        Row(1, null, null, null, null),
+        Row(2, null, null, null, null)))
+    }
+  }
+
+  test("BIN BY raises BIN_BY_INVALID_RANGE for an inverted range") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT * FROM VALUES
+          |  (TIMESTAMP '2024-01-01 00:10:00', TIMESTAMP '2024-01-01 
00:00:00', 100.0D)
+          |  AS metrics(ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))""".stripMargin)
+      checkError(
+        exception = intercept[SparkThrowable] {
+          df.collect()
+        },
+        condition = "BIN_BY_INVALID_RANGE",
+        parameters = Map(
+          "rangeStart" -> "2024-01-01 00:10:00",
+          "rangeEnd" -> "2024-01-01 00:00:00"))
+    }
+  }
+
+  test("BIN BY emits a single ratio-1.0 row for a zero-length range") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP '2024-01-01 00:02:00', TIMESTAMP '2024-01-01 
00:02:00', 100.0D)
+          |  AS metrics(ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))""".stripMargin)
+      // rangeStart == rangeEnd: one row, ratio 1.0, value kept.
+      checkAnswer(df, Seq(
+        Row(tsAt("2024-01-01 00:00:00"), tsAt("2024-01-01 00:05:00"), 1.0, 
100.0)))
+    }
+  }
+
+  test("BIN BY uses civil-time bin boundaries across a DST spring-forward") {
+    val la = ZoneId.of("America/Los_Angeles")
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+      // A 1-DAY bin spanning the 2024-03-10 spring-forward. Multi-day widths 
use civil-time
+      // arithmetic in the session zone, so boundaries land on civil midnight 
and the 2024-03-10
+      // bin is 23h wide (02:00 PST -> 03:00 PDT) while 2024-03-11 is 24h. The 
ratios split the
+      // range by real elapsed microseconds, so the 23h bin gets a smaller 
share than the 24h bin.
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP '2024-03-10 00:00:00', TIMESTAMP '2024-03-12 
00:00:00', 100.0D)
+          |  AS metrics(ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '1' DAY
+          |  ALIGN TO TIMESTAMP '2024-03-10 00:00:00' DISTRIBUTE UNIFORM 
(value))
+          |ORDER BY bin_start""".stripMargin)
+      val h = 3600L * 1000000L // one hour in micros
+      val total = 47 * h       // 23h (DST day) + 24h
+      checkAnswer(df, Seq(
+        Row(tsAt("2024-03-10 00:00:00", la), tsAt("2024-03-11 00:00:00", la),
+          ratio(23 * h, total), 100.0 * ratio(23 * h, total)),
+        Row(tsAt("2024-03-11 00:00:00", la), tsAt("2024-03-12 00:00:00", la),
+          ratio(24 * h, total), 100.0 * ratio(24 * h, total))))
+    }
+  }
+
+  test("BIN BY replicates a nested struct passthrough column across a 
multi-bin split") {
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+      // A nested struct passthrough must appear identically on every split 
sub-row.
+      val df = spark.sql(
+        """SELECT s, bin_start, value
+          |FROM VALUES
+          |  (named_struct('a', 1, 'b', 'x'),
+          |   TIMESTAMP '2024-01-01 00:00:00', TIMESTAMP '2024-01-01 
00:10:00', 100.0D)
+          |  AS metrics(s, ts_start, ts_end, value)
+          |BIN BY (
+          |  RANGE ts_start TO ts_end BIN WIDTH INTERVAL '5' MINUTE
+          |  ALIGN TO TIMESTAMP '2024-01-01 00:00:00' DISTRIBUTE UNIFORM 
(value))
+          |ORDER BY bin_start""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(Row(1, "x"), tsAt("2024-01-01 00:00:00"), 50.0),
+        Row(Row(1, "x"), tsAt("2024-01-01 00:05:00"), 50.0)))
     }
   }
 
@@ -74,28 +230,40 @@ class BinBySuite extends QueryTest with SharedSparkSession 
{
     }
   }
 
-  test("BIN BY analyzes NTZ inputs and a custom ALIGN TO with renamed 
outputs") {
+  test("BIN BY executes on NTZ inputs with the epoch default origin") {
     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)
+      // NTZ inputs default the origin to the wall-clock epoch.
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP_NTZ'2024-01-01 00:00:00', TIMESTAMP_NTZ'2024-01-01 
00:10:00', 100.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()
+          |ORDER BY bin_start""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(ntz("2024-01-01 00:00:00"), ntz("2024-01-01 00:05:00"), 0.5, 50.0),
+        Row(ntz("2024-01-01 00:05:00"), ntz("2024-01-01 00:10:00"), 0.5, 
50.0)))
+    }
+  }
 
-      // 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()
+  test("BIN BY on NTZ inputs uses UTC arithmetic and ignores the session zone 
across DST") {
+    // A multi-day NTZ bin over the LA spring-forward: NTZ ignores the session 
zone, so every day is
+    // a full 24h (no DST shortening), unlike the LTZ civil-time path. Two 
1-day bins split evenly.
+    withSQLConf(
+        SQLConf.BIN_BY_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+      val df = spark.sql(
+        """SELECT bin_start, bin_end, bin_distribute_ratio, value
+          |FROM VALUES
+          |  (TIMESTAMP_NTZ'2024-03-10 00:00:00', TIMESTAMP_NTZ'2024-03-12 
00:00:00', 100.0D)
+          |  AS t(ts_start, ts_end, value)
+          |BIN BY (RANGE ts_start TO ts_end BIN WIDTH INTERVAL '1' DAY
+          |  ALIGN TO TIMESTAMP_NTZ'2024-03-10 00:00:00' DISTRIBUTE UNIFORM 
(value))
+          |ORDER BY bin_start""".stripMargin)
+      checkAnswer(df, Seq(
+        Row(ntz("2024-03-10 00:00:00"), ntz("2024-03-11 00:00:00"), 0.5, 50.0),
+        Row(ntz("2024-03-11 00:00:00"), ntz("2024-03-12 00:00:00"), 0.5, 
50.0)))
     }
   }
 }


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


Reply via email to