Skip to content

Commit ad342ba

Browse files
authored
fix(datastore): store time zone info in Temporal.DateTime (#3393)
* fix(datastore): store time zone info in Temporal.DateTime * change to use private enum * add support for hh:mm:ss timezone format * resolve comments * refactor implementation with enums
1 parent 2166510 commit ad342ba

File tree

11 files changed

+241
-25
lines changed

11 files changed

+241
-25
lines changed

Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public struct ModelDateFormatting {
2727
public static let encodingStrategy: JSONEncoder.DateEncodingStrategy = {
2828
let strategy = JSONEncoder.DateEncodingStrategy.custom { date, encoder in
2929
var container = encoder.singleValueContainer()
30-
try container.encode(Temporal.DateTime(date).iso8601String)
30+
try container.encode(Temporal.DateTime(date, timeZone: .utc).iso8601String)
3131
}
3232
return strategy
3333
}()

Amplify/Categories/DataStore/Model/Temporal/Date.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,20 @@ extension Temporal {
1818
///
1919
/// - Note: `.medium`, `.long`, and `.full` are the same date format.
2020
public struct Date: TemporalSpec {
21+
2122
// Inherits documentation from `TemporalSpec`
2223
public let foundationDate: Foundation.Date
2324

25+
// Inherits documentation from `TemporalSpec`
26+
public let timeZone: TimeZone? = .utc
27+
2428
// Inherits documentation from `TemporalSpec`
2529
public static func now() -> Self {
26-
Temporal.Date(Foundation.Date())
30+
Temporal.Date(Foundation.Date(), timeZone: .utc)
2731
}
2832

2933
// Inherits documentation from `TemporalSpec`
30-
public init(_ date: Foundation.Date) {
34+
public init(_ date: Foundation.Date, timeZone: TimeZone?) {
3135
self.foundationDate = Temporal
3236
.iso8601Calendar
3337
.startOfDay(for: date)

Amplify/Categories/DataStore/Model/Temporal/DateTime.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,33 @@ extension Temporal {
1515
/// * `.long` => `yyyy-MM-dd'T'HH:mm:ssZZZZZ`
1616
/// * `.full` => `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ`
1717
public struct DateTime: TemporalSpec {
18+
1819
// Inherits documentation from `TemporalSpec`
1920
public let foundationDate: Foundation.Date
2021

22+
// Inherits documentation from `TemporalSpec`
23+
public let timeZone: TimeZone?
24+
2125
// Inherits documentation from `TemporalSpec`
2226
public static func now() -> Self {
23-
Temporal.DateTime(Foundation.Date())
27+
Temporal.DateTime(Foundation.Date(), timeZone: .utc)
2428
}
2529

2630
/// `Temporal.Time` of this `Temporal.DateTime`.
2731
public var time: Time {
28-
Time(foundationDate)
32+
Time(foundationDate, timeZone: timeZone)
2933
}
3034

3135
// Inherits documentation from `TemporalSpec`
32-
public init(_ date: Foundation.Date) {
36+
public init(_ date: Foundation.Date, timeZone: TimeZone? = .utc) {
3337
let calendar = Temporal.iso8601Calendar
3438
let components = calendar.dateComponents(
3539
DateTime.iso8601DateComponents,
3640
from: date
3741
)
3842

43+
self.timeZone = timeZone
44+
3945
foundationDate = calendar
4046
.date(from: components) ?? date
4147
}
@@ -57,3 +63,5 @@ extension Temporal {
5763

5864
// Allow date unit and time unit operations on `Temporal.DateTime`
5965
extension Temporal.DateTime: DateUnitOperable, TimeUnitOperable {}
66+
67+
extension Temporal.DateTime: Sendable { }

Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Foundation
1212
@usableFromInline
1313
internal struct SpecBasedDateConverting<Spec: TemporalSpec> {
1414
@usableFromInline
15-
internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> Date
15+
internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> (Date, TimeZone)
1616

1717
@usableFromInline
1818
internal let convert: DateConverter
@@ -28,19 +28,21 @@ internal struct SpecBasedDateConverting<Spec: TemporalSpec> {
2828
internal static func `default`(
2929
iso8601String: String,
3030
format: TemporalFormat? = nil
31-
) throws -> Date {
31+
) throws -> (Date, TimeZone) {
3232
let date: Foundation.Date
33+
let tz: TimeZone = TimeZone(iso8601DateString: iso8601String) ?? .utc
3334
if let format = format {
3435
date = try Temporal.date(
3536
from: iso8601String,
3637
with: [format(for: Spec.self)]
3738
)
39+
3840
} else {
3941
date = try Temporal.date(
4042
from: iso8601String,
4143
with: TemporalFormat.sortedFormats(for: Spec.self)
4244
)
4345
}
44-
return date
46+
return (date, tz)
4547
}
4648
}

Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import Foundation
1515
extension TemporalSpec where Self: Comparable {
1616

1717
public static func == (lhs: Self, rhs: Self) -> Bool {
18-
return lhs.iso8601String == rhs.iso8601String
18+
return lhs.iso8601FormattedString(format: .full, timeZone: .utc)
19+
== rhs.iso8601FormattedString(format: .full, timeZone: .utc)
1920
}
2021

2122
public static func < (lhs: Self, rhs: Self) -> Bool {
22-
return lhs.iso8601String < rhs.iso8601String
23+
return lhs.iso8601FormattedString(format: .full, timeZone: .utc)
24+
< rhs.iso8601FormattedString(format: .full, timeZone: .utc)
2325
}
2426
}
2527

Amplify/Categories/DataStore/Model/Temporal/Temporal.swift

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ public protocol TemporalSpec {
2727
/// by a Foundation `Date` instance.
2828
var foundationDate: Foundation.Date { get }
2929

30+
/// The timezone field is an optional field used to specify the timezone associated
31+
/// with a particular date.
32+
var timeZone: TimeZone? { get }
33+
3034
/// The ISO-8601 formatted string in the UTC `TimeZone`.
3135
/// - SeeAlso: `iso8601FormattedString(TemporalFormat, TimeZone) -> String`
3236
var iso8601String: String { get }
@@ -57,7 +61,7 @@ public protocol TemporalSpec {
5761
/// Constructs a `TemporalSpec` from a `Date` object.
5862
/// - Parameter date: The `Date` instance that will be used as the reference of the
5963
/// `TemporalSpec` instance.
60-
init(_ date: Foundation.Date)
64+
init(_ date: Foundation.Date, timeZone: TimeZone?)
6165

6266
/// A string representation of the underlying date formatted using ISO8601 rules.
6367
///
@@ -90,25 +94,25 @@ extension TemporalSpec {
9094
/// The ISO8601 representation of the scalar using `.full` as the format and `.utc` as `TimeZone`.
9195
/// - SeeAlso: `iso8601FormattedString(format:timeZone:)`
9296
public var iso8601String: String {
93-
iso8601FormattedString(format: .full)
97+
iso8601FormattedString(format: .full, timeZone: timeZone ?? .utc)
9498
}
9599

96100
@inlinable
97101
public init(iso8601String: String, format: TemporalFormat) throws {
98-
let date = try SpecBasedDateConverting<Self>()
102+
let (date, tz) = try SpecBasedDateConverting<Self>()
99103
.convert(iso8601String, format)
100104

101-
self.init(date)
105+
self.init(date, timeZone: tz)
102106
}
103107

104108
@inlinable
105109
public init(
106110
iso8601String: String
107111
) throws {
108-
let date = try SpecBasedDateConverting<Self>()
112+
let (date, tz) = try SpecBasedDateConverting<Self>()
109113
.convert(iso8601String, nil)
110114

111-
self.init(date)
115+
self.init(date, timeZone: tz)
112116
}
113117
}
114118

Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@ extension TemporalSpec {
3333
"""
3434
)
3535
}
36-
return Self.init(date)
36+
return Self.init(date, timeZone: timeZone)
3737
}
3838
}

Amplify/Categories/DataStore/Model/Temporal/Time.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ extension Temporal {
1818
// Inherits documentation from `TemporalSpec`
1919
public let foundationDate: Foundation.Date
2020

21+
// Inherits documentation from `TemporalSpec`
22+
public let timeZone: TimeZone? = .utc
23+
2124
// Inherits documentation from `TemporalSpec`
2225
public static func now() -> Self {
23-
Temporal.Time(Foundation.Date())
26+
Temporal.Time(Foundation.Date(), timeZone: .utc)
2427
}
2528

2629
// Inherits documentation from `TemporalSpec`
27-
public init(_ date: Foundation.Date) {
30+
public init(_ date: Foundation.Date, timeZone: TimeZone?) {
2831
// Sets the date to a fixed instant so time-only operations are safe
2932
let calendar = Temporal.iso8601Calendar
3033
var components = calendar.dateComponents(
@@ -45,7 +48,6 @@ extension Temporal {
4548
components.year = 2_000
4649
components.month = 1
4750
components.day = 1
48-
4951
self.foundationDate = calendar
5052
.date(from: components) ?? date
5153
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
9+
import Foundation
10+
11+
extension TimeZone {
12+
13+
@usableFromInline
14+
internal init?(iso8601DateString: String) {
15+
switch ISO8601TimeZonePart.from(iso8601DateString: iso8601DateString) {
16+
case .some(.utc):
17+
self.init(abbreviation: "UTC")
18+
case let .some(.hh(hours: hours)):
19+
self.init(secondsFromGMT: hours * 60 * 60)
20+
case let .some(.hhmm(hours: hours, minutes: minutes)),
21+
let .some(.hh_mm(hours: hours, minuts: minutes)):
22+
self.init(secondsFromGMT: hours * 60 * 60 +
23+
(hours > 0 ? 1 : -1) * minutes * 60)
24+
case let .some(.hh_mm_ss(hours: hours, minutes: minutes, seconds: seconds)):
25+
self.init(secondsFromGMT: hours * 60 * 60 +
26+
(hours > 0 ? 1 : -1) * minutes * 60 +
27+
(hours > 0 ? 1 : -1) * seconds)
28+
case .none:
29+
return nil
30+
}
31+
}
32+
}
33+
34+
35+
/// ISO8601 Time Zone formats
36+
/// - Note:
37+
/// `±hh:mm:ss` is not a standard of ISO8601 date formate. It's supported by `AWSDateTime` exclusively.
38+
///
39+
/// references:
40+
/// https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators
41+
/// https://docs.aws.amazon.com/appsync/latest/devguide/scalars.html#graph-ql-aws-appsync-scalars
42+
fileprivate enum ISO8601TimeZoneFormat {
43+
case utc, hh, hhmm, hh_mm, hh_mm_ss
44+
45+
var format: String {
46+
switch self {
47+
case .utc:
48+
return "Z"
49+
case .hh:
50+
return "±hh"
51+
case .hhmm:
52+
return "±hhmm"
53+
case .hh_mm:
54+
return "±hh:mm"
55+
case .hh_mm_ss:
56+
return "±hh:mm:ss"
57+
}
58+
}
59+
60+
var regex: NSRegularExpression? {
61+
switch self {
62+
case .utc:
63+
return try? NSRegularExpression(pattern: "^Z$")
64+
case .hh:
65+
return try? NSRegularExpression(pattern: "^[+-]\\d{2}$")
66+
case .hhmm:
67+
return try? NSRegularExpression(pattern: "^[+-]\\d{2}\\d{2}$")
68+
case .hh_mm:
69+
return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}$")
70+
case .hh_mm_ss:
71+
return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}:\\d{2}$")
72+
}
73+
}
74+
75+
var parts: [NSRange] {
76+
switch self {
77+
case .utc:
78+
return []
79+
case .hh:
80+
return [NSRange(location: 0, length: 3)]
81+
case .hhmm:
82+
return [
83+
NSRange(location: 0, length: 3),
84+
NSRange(location: 3, length: 2)
85+
]
86+
case .hh_mm:
87+
return [
88+
NSRange(location: 0, length: 3),
89+
NSRange(location: 4, length: 2)
90+
]
91+
case .hh_mm_ss:
92+
return [
93+
NSRange(location: 0, length: 3),
94+
NSRange(location: 4, length: 2),
95+
NSRange(location: 7, length: 2)
96+
]
97+
}
98+
}
99+
}
100+
101+
fileprivate enum ISO8601TimeZonePart {
102+
case utc
103+
case hh(hours: Int)
104+
case hhmm(hours: Int, minutes: Int)
105+
case hh_mm(hours: Int, minuts: Int)
106+
case hh_mm_ss(hours: Int, minutes: Int, seconds: Int)
107+
108+
109+
static func from(iso8601DateString: String) -> ISO8601TimeZonePart? {
110+
return tryExtract(from: iso8601DateString, with: .utc)
111+
?? tryExtract(from: iso8601DateString, with: .hh)
112+
?? tryExtract(from: iso8601DateString, with: .hhmm)
113+
?? tryExtract(from: iso8601DateString, with: .hh_mm)
114+
?? tryExtract(from: iso8601DateString, with: .hh_mm_ss)
115+
?? nil
116+
}
117+
}
118+
119+
fileprivate func tryExtract(
120+
from dateString: String,
121+
with format: ISO8601TimeZoneFormat
122+
) -> ISO8601TimeZonePart? {
123+
guard dateString.count > format.format.count else {
124+
return nil
125+
}
126+
127+
let tz = String(dateString.dropFirst(dateString.count - format.format.count))
128+
129+
guard format.regex.flatMap({
130+
$0.firstMatch(in: tz, range: NSRange(location: 0, length: tz.count))
131+
}) != nil else {
132+
return nil
133+
}
134+
135+
let parts = format.parts.compactMap { range in
136+
Range(range, in: tz).flatMap { Int(tz[$0]) }
137+
}
138+
139+
guard parts.count == format.parts.count else {
140+
return nil
141+
}
142+
143+
switch format {
144+
case .utc: return .utc
145+
case .hh: return .hh(hours: parts[0])
146+
case .hhmm: return .hhmm(hours: parts[0], minutes: parts[1])
147+
case .hh_mm: return .hh_mm(hours: parts[0], minuts: parts[1])
148+
case .hh_mm_ss: return .hh_mm_ss(hours: parts[0], minutes: parts[1], seconds: parts[2])
149+
}
150+
}

0 commit comments

Comments
 (0)