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 b46468d  [SPARK-60061] Fix Row.getAsTimestampNanos and Row.== to not 
crash on out-of-range values
b46468d is described below

commit b46468db4e7a81aad60e4af5c265a558d240fa45
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 16:14:22 2026 -0700

    [SPARK-60061] Fix Row.getAsTimestampNanos and Row.== to not crash on 
out-of-range values
    
    ### What changes were proposed in this pull request?
    
    This PR fixes two crashes in `Row`.
    
    1. `Row.getAsTimestampNanos` converts a `Date` value by rounding to the 
nearest microsecond and building `TimestampNanos(epochMicros:)`, instead of 
computing `Int64` epoch nanoseconds. A `Date` that cannot be represented, such 
as `infinity`, now throws `SparkConnectError.InvalidType`, like the other 
type-safe getters.
    2. `Row.==` compares integer values, and arrays of integer values, through 
`Int128` instead of `Int64`.
    
    ### Why are the changes needed?
    
    1. `DataFrame.collect()` returns `TIMESTAMP` and `TIMESTAMP_NTZ` columns as 
`Date`. `Int64` epoch nanoseconds only cover the years 1677-2262, so 
`getAsTimestampNanos` crashed on common sentinel values such as 
`TIMESTAMP'0001-01-01'` or `TIMESTAMP'9999-12-31'`:
       ```
       Fatal error: Double value cannot be converted to Int64 because the 
result would be less than Int64.min
       ```
       The old code also multiplied by `1_000_000_000` in `Double`, which added 
rounding error and produced wrong sub-microsecond and even microsecond digits. 
For example, `1_700_000_000.999999` became `999998` microseconds and `976` 
nanoseconds. `Date` cannot hold nanosecond precision, and Spark `TIMESTAMP` has 
microsecond precision, so rounding to the nearest microsecond gives the correct 
value.
    2. `Row(UInt64.max) == Row(UInt64.max)` crashed because `Int64(UInt64.max)` 
traps:
       ```
       Fatal error: Not enough bits to represent the passed value
       ```
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. These cases no longer crash. `getAsTimestampNanos` on a `Date` value 
now returns a microsecond-precision `TimestampNanos` (`nanosWithinMicro == 0`).
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    - `RowTests`: `getAsTimestampNanosOutOfInt64NanosRange`, 
`getAsTimestampNanosRoundsDateToMicroseconds`, `compareUInt64`, `compareData`
    - `DataFrameTests`: `collectTimestampAsTimestampNanos`, an end-to-end test 
that collects `TIMESTAMP_NTZ'0001-01-01 00:00:00'`, `TIMESTAMP_NTZ'2026-01-01 
00:00:00.999999'`, and `TIMESTAMP_NTZ'9999-12-31 00:00:00'`
    
    I also ran the tests manually against an Apache Spark 4.2.0 Spark Connect 
server.
    
    ```
    $ swift test --no-parallel --filter 'RowTests|DataFrameTests'
    ...
    Test run with 182 tests in 3 suites passed after 35.407 seconds.
    ```
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.
    
    Closes #588 from dongjoon-hyun/SPARK-60061.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 Sources/SparkConnect/Row.swift               | 11 ++++++--
 Tests/SparkConnectTests/DataFrameTests.swift | 15 ++++++++++
 Tests/SparkConnectTests/RowTests.swift       | 41 ++++++++++++++++++++++++++++
 3 files changed, 64 insertions(+), 3 deletions(-)

diff --git a/Sources/SparkConnect/Row.swift b/Sources/SparkConnect/Row.swift
index 61db2bf..668fece 100644
--- a/Sources/SparkConnect/Row.swift
+++ b/Sources/SparkConnect/Row.swift
@@ -353,6 +353,7 @@ public struct Row: Sendable, Equatable {
   /// - Returns: A `TimestampNanos` value of the field.
   /// - Throws: `SparkConnectError.InvalidArgument` if the index is out of 
range, or
   /// `SparkConnectError.InvalidType` if the value is `nil` or not convertible 
to `TimestampNanos`.
+  /// A `Date` value is rounded to the nearest microsecond.
   public func getAsTimestampNanos(_ i: Int) throws -> TimestampNanos {
     if i < 0 || i >= self.length {
       throw SparkConnectError.InvalidArgument
@@ -364,7 +365,11 @@ public struct Row: Sendable, Equatable {
       return ts
     }
     if let date = raw as? Date {
-      return TimestampNanos(epochNanos: Int64((date.timeIntervalSince1970 * 
1_000_000_000).rounded()))
+      // `Date` is not precise enough for nanoseconds, so round to the nearest 
microsecond.
+      guard let epochMicros = Int64(exactly: (date.timeIntervalSince1970 * 
1_000_000).rounded()) else {
+        throw SparkConnectError.InvalidType
+      }
+      return TimestampNanos(epochMicros: epochMicros)!
     }
     throw SparkConnectError.InvalidType
   }
@@ -420,7 +425,7 @@ public struct Row: Sendable, Equatable {
       } else if let a = x as? Bool, let b = y as? Bool {
         return a == b
       } else if let a = x as? any FixedWidthInteger, let b = y as? any 
FixedWidthInteger {
-        return Int64(a) == Int64(b)
+        return Int128(a) == Int128(b)
       } else if let a = x as? Float, let b = y as? Float {
         return a == b
       } else if let a = x as? Double, let b = y as? Double {
@@ -440,7 +445,7 @@ public struct Row: Sendable, Equatable {
       } else if let a = x as? [Bool], let b = y as? [Bool] {
         return a == b
       } else if let a = x as? [any FixedWidthInteger], let b = y as? [any 
FixedWidthInteger] {
-        return a.map { Int64($0) } == b.map { Int64($0) }
+        return a.map { Int128($0) } == b.map { Int128($0) }
       } else if let a = x as? [Float], let b = y as? [Float] {
         return a == b
       } else if let a = x as? [Double], let b = y as? [Double] {
diff --git a/Tests/SparkConnectTests/DataFrameTests.swift 
b/Tests/SparkConnectTests/DataFrameTests.swift
index f30fc8b..290817c 100644
--- a/Tests/SparkConnectTests/DataFrameTests.swift
+++ b/Tests/SparkConnectTests/DataFrameTests.swift
@@ -216,6 +216,21 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func collectTimestampAsTimestampNanos() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let expected = [
+      ("TIMESTAMP_NTZ'0001-01-01 00:00:00'", Int64(-62_135_596_800_000_000)),
+      ("TIMESTAMP_NTZ'2026-01-01 00:00:00.999999'", 
Int64(1_767_225_600_999_999)),
+      ("TIMESTAMP_NTZ'9999-12-31 00:00:00'", Int64(253_402_214_400_000_000)),
+    ]
+    for pair in expected {
+      let row = try await spark.sql("SELECT \(pair.0)").collect()[0]
+      #expect(try row.getAsTimestampNanos(0) == TimestampNanos(epochMicros: 
pair.1))
+    }
+    await spark.stop()
+  }
+
   @Test
   func collectTimestampNanos() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/RowTests.swift 
b/Tests/SparkConnectTests/RowTests.swift
index 2144a7c..4bb9a94 100644
--- a/Tests/SparkConnectTests/RowTests.swift
+++ b/Tests/SparkConnectTests/RowTests.swift
@@ -445,6 +445,30 @@ struct RowTests {
     }
   }
 
+  @Test
+  func getAsTimestampNanosOutOfInt64NanosRange() throws {
+    // 9999-12-31 00:00:00 UTC and 0001-01-01 00:00:00 UTC
+    let maxDate = Date(timeIntervalSince1970: 253_402_214_400)
+    let minDate = Date(timeIntervalSince1970: -62_135_596_800)
+    let row = Row(maxDate, minDate, Date(timeIntervalSince1970: .infinity))
+    #expect(try row.getAsTimestampNanos(0) == TimestampNanos(epochMicros: 
253_402_214_400_000_000))
+    #expect(try row.getAsTimestampNanos(1) == TimestampNanos(epochMicros: 
-62_135_596_800_000_000))
+    #expect(throws: SparkConnectError.InvalidType) {
+      try row.getAsTimestampNanos(2)
+    }
+  }
+
+  @Test
+  func getAsTimestampNanosRoundsDateToMicroseconds() throws {
+    let row = Row(
+      Date(timeIntervalSince1970: 1_700_000_000.000_001),
+      Date(timeIntervalSince1970: 1_700_000_000.999_999),
+      Date(timeIntervalSince1970: -1.000_001))
+    #expect(try row.getAsTimestampNanos(0) == TimestampNanos(epochMicros: 
1_700_000_000_000_001))
+    #expect(try row.getAsTimestampNanos(1) == TimestampNanos(epochMicros: 
1_700_000_000_999_999))
+    #expect(try row.getAsTimestampNanos(2) == TimestampNanos(epochMicros: 
-1_000_001))
+  }
+
   @Test
   func getAsDecimal() throws {
     let dec = Decimal(12.34)
@@ -575,6 +599,23 @@ struct RowTests {
     #expect([Row(1), Row(2)] != [Row(1), Row(3)])
   }
 
+  @Test
+  func compareUInt64() {
+    #expect(Row(UInt64.max) == Row(UInt64.max))
+    #expect(Row(UInt64.max) != Row(UInt64.max - 1))
+    #expect(Row(UInt64.max) != Row(-1))
+    #expect(Row(UInt64(1)) == Row(1))
+    #expect(Row([UInt64.max]) == Row([UInt64.max]))
+    #expect(Row([UInt64.max]) != Row([-1]))
+  }
+
+  @Test
+  func compareData() {
+    #expect(Row(Data([1, 2])) == Row(Data([1, 2])))
+    #expect(Row(Data([1, 2])) != Row(Data([1, 3])))
+    #expect(Row(Data()) != Row(nil))
+  }
+
   @Test
   func compareArray() {
     #expect([Row([1, 2])] == [Row([1, 2])])


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

Reply via email to