Skip to content

Commit

Permalink
fix(datastore): store time zone info in Temporal.DateTime (#3393)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
5d authored Dec 11, 2023
1 parent 2166510 commit ad342ba
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}()
Expand Down
8 changes: 6 additions & 2 deletions Amplify/Categories/DataStore/Model/Temporal/Date.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions Amplify/Categories/DataStore/Model/Temporal/DateTime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 { }
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Foundation
@usableFromInline
internal struct SpecBasedDateConverting<Spec: TemporalSpec> {
@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
Expand All @@ -28,19 +28,21 @@ internal struct SpecBasedDateConverting<Spec: TemporalSpec> {
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
16 changes: 10 additions & 6 deletions Amplify/Categories/DataStore/Model/Temporal/Temporal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<Self>()
let (date, tz) = try SpecBasedDateConverting<Self>()
.convert(iso8601String, format)

self.init(date)
self.init(date, timeZone: tz)
}

@inlinable
public init(
iso8601String: String
) throws {
let date = try SpecBasedDateConverting<Self>()
let (date, tz) = try SpecBasedDateConverting<Self>()
.convert(iso8601String, nil)

self.init(date)
self.init(date, timeZone: tz)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ extension TemporalSpec {
"""
)
}
return Self.init(date)
return Self.init(date, timeZone: timeZone)
}
}
8 changes: 5 additions & 3 deletions Amplify/Categories/DataStore/Model/Temporal/Time.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -45,7 +48,6 @@ extension Temporal {
components.year = 2_000
components.month = 1
components.day = 1

self.foundationDate = calendar
.date(from: components) ?? date
}
Expand Down
150 changes: 150 additions & 0 deletions Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
Loading

0 comments on commit ad342ba

Please sign in to comment.