From ad342ba3db637a86986110f011f5b590f8132d41 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Mon, 11 Dec 2023 14:21:18 -0800 Subject: [PATCH] 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 --- .../Model/Internal/Model+DateFormatting.swift | 2 +- .../DataStore/Model/Temporal/Date.swift | 8 +- .../DataStore/Model/Temporal/DateTime.swift | 14 +- .../Temporal/SpecBasedDateConverting.swift | 8 +- .../Model/Temporal/Temporal+Comparable.swift | 6 +- .../DataStore/Model/Temporal/Temporal.swift | 16 +- .../Model/Temporal/TemporalOperation.swift | 2 +- .../DataStore/Model/Temporal/Time.swift | 8 +- .../Model/Temporal/TimeZone+Extension.swift | 150 ++++++++++++++++++ .../DataStore/TemporalTests.swift | 46 +++++- .../CoreTests/Model+CodableTests.swift | 6 +- 11 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift diff --git a/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift b/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift index bfe1717840..8540f066a9 100644 --- a/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift +++ b/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift @@ -27,7 +27,7 @@ public struct ModelDateFormatting { public static let encodingStrategy: JSONEncoder.DateEncodingStrategy = { let strategy = JSONEncoder.DateEncodingStrategy.custom { date, encoder in var container = encoder.singleValueContainer() - try container.encode(Temporal.DateTime(date).iso8601String) + try container.encode(Temporal.DateTime(date, timeZone: .utc).iso8601String) } return strategy }() diff --git a/Amplify/Categories/DataStore/Model/Temporal/Date.swift b/Amplify/Categories/DataStore/Model/Temporal/Date.swift index ac8cfcdb1d..9b27c313e0 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Date.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Date.swift @@ -18,16 +18,20 @@ extension Temporal { /// /// - Note: `.medium`, `.long`, and `.full` are the same date format. public struct Date: TemporalSpec { + // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.Date(Foundation.Date()) + Temporal.Date(Foundation.Date(), timeZone: .utc) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone?) { self.foundationDate = Temporal .iso8601Calendar .startOfDay(for: date) diff --git a/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift b/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift index 170e7598fd..95c65e5f6e 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift @@ -15,27 +15,33 @@ extension Temporal { /// * `.long` => `yyyy-MM-dd'T'HH:mm:ssZZZZZ` /// * `.full` => `yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ` public struct DateTime: TemporalSpec { + // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.DateTime(Foundation.Date()) + Temporal.DateTime(Foundation.Date(), timeZone: .utc) } /// `Temporal.Time` of this `Temporal.DateTime`. public var time: Time { - Time(foundationDate) + Time(foundationDate, timeZone: timeZone) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone? = .utc) { let calendar = Temporal.iso8601Calendar let components = calendar.dateComponents( DateTime.iso8601DateComponents, from: date ) + self.timeZone = timeZone + foundationDate = calendar .date(from: components) ?? date } @@ -57,3 +63,5 @@ extension Temporal { // Allow date unit and time unit operations on `Temporal.DateTime` extension Temporal.DateTime: DateUnitOperable, TimeUnitOperable {} + +extension Temporal.DateTime: Sendable { } diff --git a/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift b/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift index ef43617c29..5aaa135d8d 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift @@ -12,7 +12,7 @@ import Foundation @usableFromInline internal struct SpecBasedDateConverting { @usableFromInline - internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> Date + internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> (Date, TimeZone) @usableFromInline internal let convert: DateConverter @@ -28,19 +28,21 @@ internal struct SpecBasedDateConverting { internal static func `default`( iso8601String: String, format: TemporalFormat? = nil - ) throws -> Date { + ) throws -> (Date, TimeZone) { let date: Foundation.Date + let tz: TimeZone = TimeZone(iso8601DateString: iso8601String) ?? .utc if let format = format { date = try Temporal.date( from: iso8601String, with: [format(for: Spec.self)] ) + } else { date = try Temporal.date( from: iso8601String, with: TemporalFormat.sortedFormats(for: Spec.self) ) } - return date + return (date, tz) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift b/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift index 25d4674e53..bc9e9e47e0 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift @@ -15,11 +15,13 @@ import Foundation extension TemporalSpec where Self: Comparable { public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.iso8601String == rhs.iso8601String + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + == rhs.iso8601FormattedString(format: .full, timeZone: .utc) } public static func < (lhs: Self, rhs: Self) -> Bool { - return lhs.iso8601String < rhs.iso8601String + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + < rhs.iso8601FormattedString(format: .full, timeZone: .utc) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift b/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift index a1be6cdb2e..e92f4f9435 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift @@ -27,6 +27,10 @@ public protocol TemporalSpec { /// by a Foundation `Date` instance. var foundationDate: Foundation.Date { get } + /// The timezone field is an optional field used to specify the timezone associated + /// with a particular date. + var timeZone: TimeZone? { get } + /// The ISO-8601 formatted string in the UTC `TimeZone`. /// - SeeAlso: `iso8601FormattedString(TemporalFormat, TimeZone) -> String` var iso8601String: String { get } @@ -57,7 +61,7 @@ public protocol TemporalSpec { /// Constructs a `TemporalSpec` from a `Date` object. /// - Parameter date: The `Date` instance that will be used as the reference of the /// `TemporalSpec` instance. - init(_ date: Foundation.Date) + init(_ date: Foundation.Date, timeZone: TimeZone?) /// A string representation of the underlying date formatted using ISO8601 rules. /// @@ -90,25 +94,25 @@ extension TemporalSpec { /// The ISO8601 representation of the scalar using `.full` as the format and `.utc` as `TimeZone`. /// - SeeAlso: `iso8601FormattedString(format:timeZone:)` public var iso8601String: String { - iso8601FormattedString(format: .full) + iso8601FormattedString(format: .full, timeZone: timeZone ?? .utc) } @inlinable public init(iso8601String: String, format: TemporalFormat) throws { - let date = try SpecBasedDateConverting() + let (date, tz) = try SpecBasedDateConverting() .convert(iso8601String, format) - self.init(date) + self.init(date, timeZone: tz) } @inlinable public init( iso8601String: String ) throws { - let date = try SpecBasedDateConverting() + let (date, tz) = try SpecBasedDateConverting() .convert(iso8601String, nil) - self.init(date) + self.init(date, timeZone: tz) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift b/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift index fe7ae56e4f..a413ab566d 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift @@ -33,6 +33,6 @@ extension TemporalSpec { """ ) } - return Self.init(date) + return Self.init(date, timeZone: timeZone) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Time.swift b/Amplify/Categories/DataStore/Model/Temporal/Time.swift index 9d621ddb6e..d4185e874d 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Time.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Time.swift @@ -18,13 +18,16 @@ extension Temporal { // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.Time(Foundation.Date()) + Temporal.Time(Foundation.Date(), timeZone: .utc) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone?) { // Sets the date to a fixed instant so time-only operations are safe let calendar = Temporal.iso8601Calendar var components = calendar.dateComponents( @@ -45,7 +48,6 @@ extension Temporal { components.year = 2_000 components.month = 1 components.day = 1 - self.foundationDate = calendar .date(from: components) ?? date } diff --git a/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift b/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift new file mode 100644 index 0000000000..9907557f32 --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift @@ -0,0 +1,150 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + + +import Foundation + +extension TimeZone { + + @usableFromInline + internal init?(iso8601DateString: String) { + switch ISO8601TimeZonePart.from(iso8601DateString: iso8601DateString) { + case .some(.utc): + self.init(abbreviation: "UTC") + case let .some(.hh(hours: hours)): + self.init(secondsFromGMT: hours * 60 * 60) + case let .some(.hhmm(hours: hours, minutes: minutes)), + let .some(.hh_mm(hours: hours, minuts: minutes)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60) + case let .some(.hh_mm_ss(hours: hours, minutes: minutes, seconds: seconds)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60 + + (hours > 0 ? 1 : -1) * seconds) + case .none: + return nil + } + } +} + + +/// ISO8601 Time Zone formats +/// - Note: +/// `±hh:mm:ss` is not a standard of ISO8601 date formate. It's supported by `AWSDateTime` exclusively. +/// +/// references: +/// https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators +/// https://docs.aws.amazon.com/appsync/latest/devguide/scalars.html#graph-ql-aws-appsync-scalars +fileprivate enum ISO8601TimeZoneFormat { + case utc, hh, hhmm, hh_mm, hh_mm_ss + + var format: String { + switch self { + case .utc: + return "Z" + case .hh: + return "±hh" + case .hhmm: + return "±hhmm" + case .hh_mm: + return "±hh:mm" + case .hh_mm_ss: + return "±hh:mm:ss" + } + } + + var regex: NSRegularExpression? { + switch self { + case .utc: + return try? NSRegularExpression(pattern: "^Z$") + case .hh: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}$") + case .hhmm: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}\\d{2}$") + case .hh_mm: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}$") + case .hh_mm_ss: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}:\\d{2}$") + } + } + + var parts: [NSRange] { + switch self { + case .utc: + return [] + case .hh: + return [NSRange(location: 0, length: 3)] + case .hhmm: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 3, length: 2) + ] + case .hh_mm: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2) + ] + case .hh_mm_ss: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2), + NSRange(location: 7, length: 2) + ] + } + } +} + +fileprivate enum ISO8601TimeZonePart { + case utc + case hh(hours: Int) + case hhmm(hours: Int, minutes: Int) + case hh_mm(hours: Int, minuts: Int) + case hh_mm_ss(hours: Int, minutes: Int, seconds: Int) + + + static func from(iso8601DateString: String) -> ISO8601TimeZonePart? { + return tryExtract(from: iso8601DateString, with: .utc) + ?? tryExtract(from: iso8601DateString, with: .hh) + ?? tryExtract(from: iso8601DateString, with: .hhmm) + ?? tryExtract(from: iso8601DateString, with: .hh_mm) + ?? tryExtract(from: iso8601DateString, with: .hh_mm_ss) + ?? nil + } +} + +fileprivate func tryExtract( + from dateString: String, + with format: ISO8601TimeZoneFormat +) -> ISO8601TimeZonePart? { + guard dateString.count > format.format.count else { + return nil + } + + let tz = String(dateString.dropFirst(dateString.count - format.format.count)) + + guard format.regex.flatMap({ + $0.firstMatch(in: tz, range: NSRange(location: 0, length: tz.count)) + }) != nil else { + return nil + } + + let parts = format.parts.compactMap { range in + Range(range, in: tz).flatMap { Int(tz[$0]) } + } + + guard parts.count == format.parts.count else { + return nil + } + + switch format { + case .utc: return .utc + case .hh: return .hh(hours: parts[0]) + case .hhmm: return .hhmm(hours: parts[0], minutes: parts[1]) + case .hh_mm: return .hh_mm(hours: parts[0], minuts: parts[1]) + case .hh_mm_ss: return .hh_mm_ss(hours: parts[0], minutes: parts[1], seconds: parts[2]) + } +} diff --git a/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift b/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift index 663e6b885d..914cd6ee24 100644 --- a/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift +++ b/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift @@ -42,6 +42,50 @@ class TemporalTests: XCTestCase { } } + /// - Given: a `DateTime` string in ISO8601 format + /// - When: + /// - the input has time zone info + /// - Then: + /// - DateTime should be parsed correctly with time zone info + /// - Date should be parsed with utc time zone + /// - Time should be parsed with utc time zone + func testConvertToIso8601String() { + do { + let datetime = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03-08:00") + XCTAssertEqual(datetime.iso8601String, "2023-11-30T11:04:03.000-08:00") + let datetime0 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03+08:00") + XCTAssertEqual(datetime0.iso8601String, "2023-11-30T11:04:03.000+08:00") + let datetime1 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03.322-0800") + XCTAssertEqual(datetime1.iso8601String, "2023-11-30T11:04:03.322-08:00") + let datetime2 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0830") + XCTAssertEqual(datetime2.iso8601String, "2023-11-30T14:09:27.128-08:30") + let datetime3 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0339") + XCTAssertEqual(datetime3.iso8601String, "2023-11-30T14:09:27.128-03:39") + let datetime4 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0000") + XCTAssertEqual(datetime4.iso8601String, "2023-11-30T14:09:27.128Z") + let datetime5 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03+08:00:21") + XCTAssertEqual(datetime5.iso8601String, "2023-11-30T11:03:42.000+08:00") + let datetime6 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03-08:00:21") + XCTAssertEqual(datetime6.iso8601String, "2023-11-30T11:04:24.000-08:00") + let datetime7 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128Z") + XCTAssertEqual(datetime7.iso8601String, "2023-11-30T14:09:27.128Z") + if #available(iOS 15.0, tvOS 15.0, *) { + let now = Date.now + let dateFormatter = DateFormatter() + dateFormatter.timeZone = .init(abbreviation: "HKT") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + let datetime7 = Temporal.DateTime(now, timeZone: .init(abbreviation: "HKT")) + XCTAssertEqual(datetime7.iso8601String, "\(dateFormatter.string(from: now))+08:00") + } + let date = try Temporal.Date(iso8601String: "2023-11-30-08:00") + XCTAssertEqual(date.iso8601String, "2023-11-30Z") + let time = try Temporal.Time(iso8601String: "11:00:00.000-08:00") + XCTAssertEqual(time.iso8601String, "19:00:00.000Z") + } catch { + XCTFail(error.localizedDescription) + } + } + /// - Given: a `DateTime` string /// - When: /// - the input format is `yyyy-MM-dd'T'HH:mm:ss'Z'` @@ -144,7 +188,7 @@ class TemporalTests: XCTestCase { func testFullDateTimeParsingOnPST() { do { let datetime = try Temporal.DateTime(iso8601String: "2020-01-20T08:00:00.180-08:00") - XCTAssertEqual(datetime.iso8601String, "2020-01-20T16:00:00.180Z") + XCTAssertEqual(datetime.iso8601String, "2020-01-20T08:00:00.180-08:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .short, timeZone: pst), "2020-01-20T08:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .short, timeZone: .utc), "2020-01-20T16:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .medium, timeZone: pst), "2020-01-20T08:00:00") diff --git a/AmplifyTests/CoreTests/Model+CodableTests.swift b/AmplifyTests/CoreTests/Model+CodableTests.swift index 2333efa45c..c03c44edc7 100644 --- a/AmplifyTests/CoreTests/Model+CodableTests.swift +++ b/AmplifyTests/CoreTests/Model+CodableTests.swift @@ -24,7 +24,7 @@ class ModelCodableTests: XCTestCase { } func testToJSON() throws { - let createdAt = Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123)) + let createdAt = Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123), timeZone: .utc) let post = Post(id: "post-1", title: "title", content: "content", @@ -39,7 +39,7 @@ class ModelCodableTests: XCTestCase { XCTAssertEqual(post?.id, "post-1") XCTAssertEqual(post?.title, "title") XCTAssertEqual(post?.content, "content") - XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123))) + XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123), timeZone: .utc)) } func testDecodeWithoutFractionalSeconds() throws { @@ -47,6 +47,6 @@ class ModelCodableTests: XCTestCase { XCTAssertEqual(post?.id, "post-1") XCTAssertEqual(post?.title, "title") XCTAssertEqual(post?.content, "content") - XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000))) + XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000), timeZone: .utc)) } }