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

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


The following commit(s) were added to refs/heads/main by this push:
     new ac834d8  [SPARK-58571] Support `TIME` type values in 
`DataFrame.collect`
ac834d8 is described below

commit ac834d8636eb727d8ca8af4207a67c74586986d8
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Aug 4 11:29:17 2026 -0700

    [SPARK-58571] Support `TIME` type values in `DataFrame.collect`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to support `TIME` type values in `DataFrame.collect` by 
introducing a new
    `LocalTime` struct, following up SPARK-58568 which covered the schema path.
    
    - A new public struct `LocalTime` represents a time of day without a date 
or a time zone,
      like `java.time.LocalTime` of the Scala client and `datetime.time` of 
PySpark. It stores
      `nanoOfDay` and exposes `hour`/`minute`/`second`/`nanosecond` components.
    - `DataFrame.collect` now decodes Arrow `time64` columns into `LocalTime` 
values. Apache
      Spark serializes `TIME(n)` columns as Arrow `time64(NANOSECOND)` 
regardless of precision.
    - `Row.==` supports `LocalTime` value comparison.
    
    No vendored `Arrow*.swift` file is touched; the Arrow layer already decodes 
`time64`.
    
    ### Why are the changes needed?
    
    `DataFrame.collect` silently returned `nil` for every `TIME` column value 
because the
    Arrow-to-Row conversion had no `time64` case and fell through to the 
`String` cast.
    
    ```swift
    let df = try await spark.sql("SELECT TIME'12:34:56'")
    try await df.collect()  // Before: [Row(nil)]
                            // After:  [Row(LocalTime(hour: 12, minute: 34, 
second: 56))]
    ```
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, `DataFrame.collect` returns `LocalTime` values instead of `nil` for 
`TIME` columns,
    and a new public type `LocalTime` is added. This is a bug fix from the 
unreleased perspective.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #481 from dongjoon-hyun/SPARK-58571.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 Sources/SparkConnect/DataFrame+Actions.swift       | 12 +++
 .../Documentation.docc/SparkConnect.md             |  1 +
 Sources/SparkConnect/LocalTime.swift               | 92 ++++++++++++++++++++++
 Sources/SparkConnect/Row.swift                     |  2 +
 Tests/SparkConnectTests/DataFrameTests.swift       | 26 ++++++
 Tests/SparkConnectTests/LocalTimeTests.swift       | 69 ++++++++++++++++
 6 files changed, 202 insertions(+)

diff --git a/Sources/SparkConnect/DataFrame+Actions.swift 
b/Sources/SparkConnect/DataFrame+Actions.swift
index 89a004e..3b9c20f 100644
--- a/Sources/SparkConnect/DataFrame+Actions.swift
+++ b/Sources/SparkConnect/DataFrame+Actions.swift
@@ -133,6 +133,18 @@ extension DataFrame {
               values.append(array.asAny(i) as? Decimal)
             case .primitiveInfo(.date32):
               values.append(array.asAny(i) as! Date)
+            case .timeInfo(.time64):
+              // Spark serializes `TIME` columns as Arrow `time64` values 
since midnight.
+              let time64Type = column.data.type as! ArrowTypeTime64
+              let time = array.asAny(i) as! Int64
+              let nanoOfDay =
+                switch time64Type.unit {
+                case .microseconds:
+                  time * 1_000
+                case .nanoseconds:
+                  time
+                }
+              values.append(LocalTime(nanoOfDay: nanoOfDay))
             case .timeInfo(.timestamp):
               let timestampType = column.data.type as! ArrowTypeTimestamp
               let timestamp = array.asAny(i) as! Int64
diff --git a/Sources/SparkConnect/Documentation.docc/SparkConnect.md 
b/Sources/SparkConnect/Documentation.docc/SparkConnect.md
index 092a7b0..3d6bc5a 100644
--- a/Sources/SparkConnect/Documentation.docc/SparkConnect.md
+++ b/Sources/SparkConnect/Documentation.docc/SparkConnect.md
@@ -30,6 +30,7 @@ SparkConnect is a modern Swift library that provides a native 
interface to Apach
 - ``DataFrame``
 - ``GroupedData``
 - ``Row``
+- ``LocalTime``
 - ``StorageLevel``
 
 ### Data I/O
diff --git a/Sources/SparkConnect/LocalTime.swift 
b/Sources/SparkConnect/LocalTime.swift
new file mode 100644
index 0000000..28d92f7
--- /dev/null
+++ b/Sources/SparkConnect/LocalTime.swift
@@ -0,0 +1,92 @@
+//
+// 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.
+//
+#if canImport(FoundationEssentials)
+import FoundationEssentials
+#else
+import Foundation
+#endif
+
+/// A time of day without a date or a time zone, like `java.time.LocalTime`.
+/// Values of Spark's `TIME` type are represented as `LocalTime` in ``Row``s.
+public struct LocalTime: Sendable, Equatable, Hashable, 
CustomStringConvertible {
+  static let nanosPerSecond: Int64 = 1_000_000_000
+  static let nanosPerMinute: Int64 = 60 * nanosPerSecond
+  static let nanosPerHour: Int64 = 60 * nanosPerMinute
+  static let nanosPerDay: Int64 = 24 * nanosPerHour
+
+  /// The number of nanoseconds since midnight, in the range 
`0..<86_400_000_000_000`.
+  public let nanoOfDay: Int64
+
+  /// Creates a `LocalTime` from a time of day.
+  /// - Parameters:
+  ///   - hour: An hour in the range `0...23`.
+  ///   - minute: A minute in the range `0...59`.
+  ///   - second: A second in the range `0...59`.
+  ///   - nanosecond: A nanosecond in the range `0...999_999_999`.
+  /// - Returns: `nil` if any component is out of range.
+  public init?(hour: Int, minute: Int, second: Int = 0, nanosecond: Int = 0) {
+    guard (0..<24).contains(hour), (0..<60).contains(minute), 
(0..<60).contains(second),
+      (0..<1_000_000_000).contains(nanosecond)
+    else {
+      return nil
+    }
+    self.nanoOfDay =
+      Int64(hour) * Self.nanosPerHour + Int64(minute) * Self.nanosPerMinute
+      + Int64(second) * Self.nanosPerSecond + Int64(nanosecond)
+  }
+
+  /// Creates a `LocalTime` from the number of nanoseconds since midnight.
+  /// - Parameter nanoOfDay: A nanosecond of the day in the range 
`0..<86_400_000_000_000`.
+  /// - Returns: `nil` if `nanoOfDay` is out of range.
+  public init?(nanoOfDay: Int64) {
+    guard (0..<Self.nanosPerDay).contains(nanoOfDay) else {
+      return nil
+    }
+    self.nanoOfDay = nanoOfDay
+  }
+
+  /// The hour of the day, in the range `0...23`.
+  public var hour: Int { Int(nanoOfDay / Self.nanosPerHour) }
+
+  /// The minute of the hour, in the range `0...59`.
+  public var minute: Int { Int((nanoOfDay / Self.nanosPerMinute) % 60) }
+
+  /// The second of the minute, in the range `0...59`.
+  public var second: Int { Int((nanoOfDay / Self.nanosPerSecond) % 60) }
+
+  /// The nanosecond of the second, in the range `0...999_999_999`.
+  public var nanosecond: Int { Int(nanoOfDay % Self.nanosPerSecond) }
+
+  /// A string like `12:34:56`, `12:34:56.123`, `12:34:56.123456`, or
+  /// `12:34:56.123456789` depending on the smallest non-zero fractional unit.
+  public var description: String {
+    var result = String(format: "%02d:%02d:%02d", hour, minute, second)
+    let nanosecond = self.nanosecond
+    if nanosecond != 0 {
+      if nanosecond % 1_000_000 == 0 {
+        result += String(format: ".%03d", nanosecond / 1_000_000)
+      } else if nanosecond % 1_000 == 0 {
+        result += String(format: ".%06d", nanosecond / 1_000)
+      } else {
+        result += String(format: ".%09d", nanosecond)
+      }
+    }
+    return result
+  }
+}
diff --git a/Sources/SparkConnect/Row.swift b/Sources/SparkConnect/Row.swift
index caeda7c..ad3a983 100644
--- a/Sources/SparkConnect/Row.swift
+++ b/Sources/SparkConnect/Row.swift
@@ -137,6 +137,8 @@ public struct Row: Sendable, Equatable {
         return a == b
       } else if let a = x as? Date, let b = y as? Date {
         return a == b
+      } else if let a = x as? LocalTime, let b = y as? LocalTime {
+        return a == b
       } else if let a = x as? String, let b = y as? String {
         return a == b
       } else if let a = x as? [Bool], let b = y as? [Bool] {
diff --git a/Tests/SparkConnectTests/DataFrameTests.swift 
b/Tests/SparkConnectTests/DataFrameTests.swift
index d812745..e4d0858 100644
--- a/Tests/SparkConnectTests/DataFrameTests.swift
+++ b/Tests/SparkConnectTests/DataFrameTests.swift
@@ -171,6 +171,32 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func collectTime() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    if await spark.version >= "4.2" {
+      try await spark.conf.set("spark.sql.timeType.enabled", "true")
+      let expected = [
+        ("TIME'00:00:00'", LocalTime(hour: 0, minute: 0)),
+        ("TIME'12:34:56'", LocalTime(hour: 12, minute: 34, second: 56)),
+        ("CAST(TIME'12:34:56' AS TIME(0))", LocalTime(hour: 12, minute: 34, 
second: 56)),
+        (
+          "CAST(TIME'12:34:56.123' AS TIME(3))",
+          LocalTime(hour: 12, minute: 34, second: 56, nanosecond: 123_000_000)
+        ),
+        (
+          "TIME'23:59:59.999999'",
+          LocalTime(hour: 23, minute: 59, second: 59, nanosecond: 999_999_000)
+        ),
+      ]
+      for pair in expected {
+        #expect(try await spark.sql("SELECT \(pair.0)").collect() == 
[Row(pair.1)])
+      }
+      #expect(try await spark.sql("SELECT CAST(NULL AS TIME)").collect() == 
[Row(nil)])
+    }
+    await spark.stop()
+  }
+
   @Test
   func array() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/LocalTimeTests.swift 
b/Tests/SparkConnectTests/LocalTimeTests.swift
new file mode 100644
index 0000000..d3cfac4
--- /dev/null
+++ b/Tests/SparkConnectTests/LocalTimeTests.swift
@@ -0,0 +1,69 @@
+//
+// 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.
+//
+
+import SparkConnect
+import Testing
+
+/// A test suite for ``LocalTime``.
+struct LocalTimeTests {
+  @Test
+  func components() async throws {
+    let time = try #require(LocalTime(hour: 12, minute: 34, second: 56, 
nanosecond: 123_456_789))
+    #expect(time.hour == 12)
+    #expect(time.minute == 34)
+    #expect(time.second == 56)
+    #expect(time.nanosecond == 123_456_789)
+    #expect(time.nanoOfDay == 45_296_123_456_789)
+  }
+
+  @Test
+  func componentBounds() async throws {
+    #expect(LocalTime(hour: 0, minute: 0) != nil)
+    #expect(LocalTime(hour: 23, minute: 59, second: 59, nanosecond: 
999_999_999) != nil)
+    #expect(LocalTime(hour: -1, minute: 0) == nil)
+    #expect(LocalTime(hour: 24, minute: 0) == nil)
+    #expect(LocalTime(hour: 0, minute: 60) == nil)
+    #expect(LocalTime(hour: 0, minute: 0, second: 60) == nil)
+    #expect(LocalTime(hour: 0, minute: 0, second: 0, nanosecond: 
1_000_000_000) == nil)
+  }
+
+  @Test
+  func nanoOfDay() async throws {
+    #expect(LocalTime(nanoOfDay: 0) == LocalTime(hour: 0, minute: 0))
+    #expect(LocalTime(nanoOfDay: 45_296_000_000_000) == LocalTime(hour: 12, 
minute: 34, second: 56))
+    #expect(LocalTime(nanoOfDay: 86_399_999_999_999) != nil)
+    #expect(LocalTime(nanoOfDay: -1) == nil)
+    #expect(LocalTime(nanoOfDay: 86_400_000_000_000) == nil)
+  }
+
+  @Test
+  func description() async throws {
+    #expect(LocalTime(hour: 1, minute: 2, second: 3)?.description == 
"01:02:03")
+    #expect(LocalTime(hour: 12, minute: 34, second: 56)?.description == 
"12:34:56")
+    #expect(
+      LocalTime(hour: 12, minute: 34, second: 56, nanosecond: 
123_000_000)?.description
+        == "12:34:56.123")
+    #expect(
+      LocalTime(hour: 12, minute: 34, second: 56, nanosecond: 
123_456_000)?.description
+        == "12:34:56.123456")
+    #expect(
+      LocalTime(hour: 12, minute: 34, second: 56, nanosecond: 
123_456_789)?.description
+        == "12:34:56.123456789")
+  }
+}


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

Reply via email to