Skip to content

Commit 7ddb4e0

Browse files
fix: Date/Time type fixes
1 parent b20c2aa commit 7ddb4e0

File tree

8 files changed

+209
-95
lines changed

8 files changed

+209
-95
lines changed

Sources/Haystack/Date.swift

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,56 @@ import Foundation
33
public struct Date: Val {
44
public static var valType: ValType { .Date }
55

6-
// TODO: Ensure no sub-day components
7-
public let date: Foundation.Date
8-
9-
public init(date: Foundation.Date) {
10-
self.date = date
11-
}
12-
13-
public init(_ isoString: String) throws {
14-
guard let date = dateFormatter.date(from: isoString) else {
15-
throw ValError.invalidDateFormat(isoString)
16-
}
17-
self.init(date: date)
18-
}
6+
public let year: Int
7+
public let month: Int
8+
public let day: Int
199

2010
public init(
2111
year: Int,
2212
month: Int,
2313
day: Int
2414
) throws {
2515
let components = DateComponents(
26-
calendar: calendar,
27-
timeZone: .init(secondsFromGMT: 0),
2816
year: year,
2917
month: month,
30-
day: day,
31-
hour: 0,
32-
minute: 0,
33-
second: 0,
34-
nanosecond: 0
18+
day: day
3519
)
36-
guard let date = components.date else {
20+
guard components.isValidDate(in: calendar) else {
3721
throw ValError.invalidDateDefinition
3822
}
39-
self.date = date
23+
24+
self.year = year
25+
self.month = month
26+
self.day = day
4027
}
4128

42-
public func toZinc() -> String {
43-
return dateFormatter.string(from: date)
44-
}
45-
46-
var year: Int {
47-
calendar.component(.year, from: date)
29+
public init(_ isoString: String) throws {
30+
let dashSplit = isoString.split(separator: "-")
31+
guard
32+
dashSplit.count == 3,
33+
let year = Int(dashSplit[0]),
34+
let month = Int(dashSplit[1]),
35+
let day = Int(dashSplit[2])
36+
else {
37+
throw ValError.invalidDateFormat(isoString)
38+
}
39+
40+
try self.init(
41+
year: year,
42+
month: month,
43+
day: day
44+
)
4845
}
4946

50-
var month: Int {
51-
calendar.component(.month, from: date)
47+
public func toZinc() -> String {
48+
return isoString
5249
}
5350

54-
var day: Int {
55-
calendar.component(.day, from: date)
51+
var isoString: String {
52+
let yearStr = String(format: "%04d", arguments: [year])
53+
let monthStr = String(format: "%02d", arguments: [month])
54+
let dayStr = String(format: "%02d", arguments: [day])
55+
return "\(yearStr)-\(monthStr)-\(dayStr)"
5656
}
5757
}
5858

@@ -110,7 +110,6 @@ extension Date {
110110
public func encode(to encoder: Encoder) throws {
111111
var container = encoder.container(keyedBy: Self.CodingKeys)
112112
try container.encode(Self.kindValue, forKey: ._kind)
113-
let isoString = dateFormatter.string(from: self.date)
114113
try container.encode(isoString, forKey: .val)
115114
}
116115
}

Sources/Haystack/DateTime.swift

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ public struct DateTime: Val {
55
public static let utcName = "UTC"
66

77
public let date: Foundation.Date
8+
public let gmtOffset: Int
89
public let timezone: String
910

10-
public init(date: Foundation.Date, gmtOffset: Int = 0, timezone: String = Self.utcName) {
11+
public init(date: Foundation.Date) {
1112
self.date = date
12-
self.timezone = timezone
13+
self.gmtOffset = 0
14+
self.timezone = Self.utcName
1315
}
1416

1517
public init(
@@ -38,6 +40,7 @@ public struct DateTime: Val {
3840
throw ValError.invalidDateTimeDefinition
3941
}
4042
self.date = date
43+
self.gmtOffset = gmtOffset
4144
self.timezone = timezone
4245
}
4346

@@ -62,13 +65,16 @@ public struct DateTime: Val {
6265
throw ValError.invalidDateTimeDefinition
6366
}
6467
self.date = date
68+
self.gmtOffset = gmtOffset
6569
self.timezone = timezone
6670
}
6771

6872
public init(_ string: String) throws {
6973
let splits = string.split(separator: " ")
70-
let dateTimeStr = String(splits[0])
71-
self.date = try Self.dateFromString(dateTimeStr)
74+
let isoString = String(splits[0])
75+
let (date, gmtOffset) = try Self.dateFromString(isoString)
76+
self.date = date
77+
self.gmtOffset = gmtOffset
7278
if splits.count > 1 {
7379
self.timezone = String(splits[1])
7480
} else {
@@ -89,33 +95,99 @@ public struct DateTime: Val {
8995
return zinc
9096
}
9197

92-
static func dateFromString(_ isoString: String) throws -> Foundation.Date {
93-
if let date = dateTimeFormatter.date(from: isoString) {
94-
return date
95-
} else if let date = dateTimeWithMillisFormatter.date(from: isoString) {
96-
return date
97-
} else {
98+
static func dateFromString(_ isoString: String) throws -> (Foundation.Date, Int) {
99+
// Must use Regex so we can preserve GMT offset details. dateFormatter doesn't give us this
100+
let expr = try NSRegularExpression(pattern: #"(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}\.?\d*)([+-]\d{2}:\d{2}|Z)"#)
101+
guard let match = expr.firstMatch(
102+
in: isoString,
103+
range: NSRange(isoString.startIndex..<isoString.endIndex, in: isoString)
104+
) else {
98105
throw ValError.invalidDateTimeFormat(isoString)
99106
}
107+
108+
guard
109+
let dateRange = Range(match.range(at: 1), in: isoString),
110+
let timeRange = Range(match.range(at: 2), in: isoString),
111+
let offsetRange = Range(match.range(at: 3), in: isoString)
112+
else {
113+
throw ValError.invalidDateTimeFormat(isoString)
114+
}
115+
116+
let date = try Date(String(isoString[dateRange]))
117+
let time = try Time(String(isoString[timeRange]))
118+
let offsetStr = String(isoString[offsetRange])
119+
120+
let gmtOffset: Int
121+
if offsetStr == "Z" {
122+
gmtOffset = 0
123+
} else {
124+
let offsetExpr = try NSRegularExpression(pattern: #"([+-])(\d{2}):(\d{2})"#)
125+
guard let offsetMatch = offsetExpr.firstMatch(
126+
in: offsetStr,
127+
range: NSRange(offsetStr.startIndex..<offsetStr.endIndex, in: offsetStr)
128+
) else {
129+
throw ValError.invalidDateTimeFormat(isoString)
130+
}
131+
132+
guard
133+
let symbolRange = Range(offsetMatch.range(at: 1), in: offsetStr),
134+
let hourRange = Range(offsetMatch.range(at: 2), in: offsetStr),
135+
let minuteRange = Range(offsetMatch.range(at: 3), in: offsetStr),
136+
let hour = Int(String(offsetStr[hourRange])),
137+
let minute = Int(String(offsetStr[minuteRange]))
138+
else {
139+
throw ValError.invalidDateTimeFormat(isoString)
140+
}
141+
let sign = String(offsetStr[symbolRange]) == "+" ? 1 : -1
142+
143+
gmtOffset = sign * ((hour * 60 * 60) + (minute * 60))
144+
}
145+
146+
let components = DateComponents(
147+
calendar: calendar,
148+
timeZone: .init(secondsFromGMT: gmtOffset),
149+
year: date.year,
150+
month: date.month,
151+
day: date.day,
152+
hour: time.hour,
153+
minute: time.minute,
154+
second: time.second,
155+
nanosecond: time.millisecond * 1_000_000
156+
)
157+
guard let date = components.date else {
158+
throw ValError.invalidDateTimeDefinition
159+
}
160+
161+
return (date, gmtOffset)
162+
163+
// if let date = dateTimeFormatter.date(from: isoString) {
164+
// return date
165+
// } else if let date = dateTimeWithMillisFormatter.date(from: isoString) {
166+
// return date
167+
// } else {
168+
// throw ValError.invalidDateTimeFormat(isoString)
169+
// }
100170
}
101171

102172
private var hasMilliseconds: Bool {
103173
return calendar.component(.nanosecond, from: date) != 0
104174
}
105-
}
106-
107-
/// Singleton Haystack DateTime formatter
108-
var dateTimeFormatter: ISO8601DateFormatter {
109-
let formatter = ISO8601DateFormatter()
110-
formatter.formatOptions = [.withInternetDateTime]
111-
return formatter
112-
}
175+
176+
/// Singleton Haystack DateTime formatter
177+
var dateTimeFormatter: ISO8601DateFormatter {
178+
let formatter = ISO8601DateFormatter()
179+
formatter.formatOptions = [.withInternetDateTime]
180+
formatter.timeZone = .init(secondsFromGMT: gmtOffset)
181+
return formatter
182+
}
113183

114-
/// Singleton Haystack DateTime formatter with fractional second support
115-
var dateTimeWithMillisFormatter: ISO8601DateFormatter {
116-
let formatter = ISO8601DateFormatter()
117-
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
118-
return formatter
184+
/// Singleton Haystack DateTime formatter with fractional second support
185+
var dateTimeWithMillisFormatter: ISO8601DateFormatter {
186+
let formatter = ISO8601DateFormatter()
187+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
188+
formatter.timeZone = .init(secondsFromGMT: gmtOffset)
189+
return formatter
190+
}
119191
}
120192

121193
var calendar = Calendar(identifier: .gregorian)
@@ -153,7 +225,9 @@ extension DateTime {
153225

154226
let isoString = try container.decode(String.self, forKey: .val)
155227
do {
156-
self.date = try Self.dateFromString(isoString)
228+
let (date, gmtOffset) = try Self.dateFromString(isoString)
229+
self.date = date
230+
self.gmtOffset = gmtOffset
157231
} catch {
158232
throw DecodingError.typeMismatch(
159233
Self.self,

Sources/Haystack/Time.swift

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,32 @@ public struct Time: Val {
88
public let second: Int
99
public let millisecond: Int
1010

11-
public init(hour: Int, minute: Int, second: Int, millisecond: Int = 0) {
11+
public init(hour: Int, minute: Int, second: Int, millisecond: Int = 0) throws {
12+
guard
13+
0 <= hour, hour < 24,
14+
0 <= minute, minute < 60,
15+
0 <= second, second < 60,
16+
0 <= millisecond, millisecond < 1000
17+
else {
18+
throw ValError.invalidTimeDefinition
19+
}
20+
1221
self.hour = hour
1322
self.minute = minute
1423
self.second = second
1524
self.millisecond = millisecond
1625
}
1726

1827
public init(_ isoString: String) throws {
19-
let hourStr = isoString.split(separator: ":")[0]
20-
let minuteStr = isoString.split(separator: ":")[1]
21-
let secondAndMilliStr = isoString.split(separator: ":")[2]
22-
let secondStr = secondAndMilliStr.split(separator: ".")[0]
28+
let colonSplit = isoString.split(separator: ":")
29+
guard colonSplit.count == 3 else {
30+
throw ValError.invalidTimeFormat(isoString)
31+
}
32+
let hourStr = colonSplit[0]
33+
let minuteStr = colonSplit[1]
34+
let secondAndMilliStr = colonSplit[2]
35+
let secondAndMilliSplit = secondAndMilliStr.split(separator: ".")
36+
let secondStr = secondAndMilliSplit[0]
2337

2438
guard
2539
let hour = Int(hourStr),
@@ -34,7 +48,7 @@ public struct Time: Val {
3448
self.second = second
3549

3650
if secondAndMilliStr.contains(".") {
37-
var millisecondStr = secondAndMilliStr.split(separator: ".")[1]
51+
var millisecondStr = secondAndMilliSplit[1]
3852
guard millisecondStr.count <= 3 else {
3953
throw ValError.invalidTimeFormat(isoString)
4054
}

Sources/Haystack/ValError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ public enum ValError: Error {
44
case invalidDateFormat(String)
55
case invalidDateTimeDefinition
66
case invalidDateTimeFormat(String)
7+
case invalidTimeDefinition
78
case invalidTimeFormat(String)
89
}

Tests/HaystackTests/DateTests.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import Haystack
33

44
final class DateTests: XCTestCase {
55
func testJsonCoding() throws {
6-
let value = Date(date: .init(timeIntervalSince1970: 0))
7-
let jsonString = #"{"_kind":"date","val":"1970-01-01"}"#
6+
let value = try Date(
7+
year: 1991,
8+
month: 6,
9+
day: 7
10+
)
11+
let jsonString = #"{"_kind":"date","val":"1991-06-07"}"#
812

913
let encodedData = try JSONEncoder().encode(value)
1014
XCTAssertEqual(
@@ -21,8 +25,12 @@ final class DateTests: XCTestCase {
2125

2226
func testToZinc() throws {
2327
XCTAssertEqual(
24-
Date(date: .init(timeIntervalSince1970: 0)).toZinc(),
25-
"1970-01-01"
28+
try Date(
29+
year: 1991,
30+
month: 6,
31+
day: 7
32+
).toZinc(),
33+
"1991-06-07"
2634
)
2735
}
2836
}

0 commit comments

Comments
 (0)