diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakeValueDecoder.swift b/Plugins/SnowflakeDriverPlugin/SnowflakeValueDecoder.swift index c85b6fa6d..4aa2888cc 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakeValueDecoder.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakeValueDecoder.swift @@ -67,7 +67,7 @@ enum SnowflakeValueDecoder { let fields = raw.split(separator: " ", omittingEmptySubsequences: true) guard fields.count == 2, let biased = Int(fields[1]) else { return .text(raw) } let offsetMinutes = biased - offsetBias - guard abs(offsetMinutes) < minutesPerDay else { return .text(raw) } + guard abs(offsetMinutes) <= maximumOffsetMinutes else { return .text(raw) } return timestamp( fromEpochSeconds: String(fields[0]), scale: scale, @@ -117,8 +117,13 @@ enum SnowflakeValueDecoder { // MARK: - Constants private static let secondsPerDay = 86_400 - private static let minutesPerDay = 1_440 private static let offsetBias = 1_440 + + /// `TimeZone(secondsFromGMT:)` returns nil beyond eighteen hours, and `DatabaseDateParser` reads + /// that nil as GMT, so a `+18:30` suffix would come back as an instant eighteen and a half hours + /// from the one Snowflake sent. A payload that cannot survive the round trip keeps its raw text + /// instead of being written in a spelling the reader will misread. + private static let maximumOffsetMinutes = 1_080 } /// How a decoded timestamp names the zone it was read in. `TIMESTAMP_NTZ` genuinely has none, so it diff --git a/TableProTests/Plugins/SnowflakeValueDecoderTests.swift b/TableProTests/Plugins/SnowflakeValueDecoderTests.swift index 3403e5636..b2018f804 100644 --- a/TableProTests/Plugins/SnowflakeValueDecoderTests.swift +++ b/TableProTests/Plugins/SnowflakeValueDecoderTests.swift @@ -155,6 +155,17 @@ struct SnowflakeValueDecoderTests { #expect(decodedText("1616173619.000000000 9999", "timestamp_tz", scale: 0) == "1616173619.000000000 9999") } + /// `TimeZone(secondsFromGMT:)` is nil beyond eighteen hours and the shared parser reads that nil + /// as GMT, so emitting `+18:30` would move the instant by eighteen and a half hours. Eighteen + /// hours exactly is the last offset that survives the round trip. + @Test("An offset the shared parser cannot represent keeps its raw text") + func testOutOfRangeOffsetIsPreserved() { + #expect(decodedText("0.000000000 2520", "timestamp_tz", scale: 0) == "1970-01-01 18:00:00+18:00") + #expect(decodedText("0.000000000 360", "timestamp_tz", scale: 0) == "1969-12-31 06:00:00-18:00") + #expect(decodedText("0.000000000 2550", "timestamp_tz", scale: 0) == "0.000000000 2550") + #expect(decodedText("0.000000000 330", "timestamp_tz", scale: 0) == "0.000000000 330") + } + /// The wire writes nine decimal places, so the value carries more significant digits than a /// `Double` holds. Parsing it as a floating-point number drops the last nanoseconds silently. @Test("Nanosecond precision survives decoding")