From 21af7d1da13873c7ad935fbf4425a464f6b1e172 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sun, 20 Jul 2025 14:00:01 +1000 Subject: [PATCH 1/3] DateEncodingStrategy --- Sources/KeyValueEncoder.swift | 79 +++++++++++++++------- Tests/KeyValueEncoderTests.swift | 103 ++++++++++++++++++++--------- Tests/KeyValueEncoderXCTests.swift | 16 ++++- 3 files changed, 141 insertions(+), 57 deletions(-) diff --git a/Sources/KeyValueEncoder.swift b/Sources/KeyValueEncoder.swift index c72a12c..b51aeb5 100644 --- a/Sources/KeyValueEncoder.swift +++ b/Sources/KeyValueEncoder.swift @@ -37,12 +37,16 @@ public struct KeyValueEncoder: Sendable { /// Contextual user-provided information for use during encoding. public var userInfo: [CodingUserInfoKey: any Sendable] - /// The strategy to use for encoding `nil`. Defaults to `Optional.none` which can be cast to any optional type. - public var nilEncodingStrategy: NilEncodingStrategy = .default + /// The strategy to use for encoding Date types. + public var dateEncodingStrategy: DateEncodingStrategy = .date /// The strategy to use for encoding each types keys. public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys + /// The strategy to use for encoding `nil`. Defaults to `Optional.none` which can be cast to any optional type. + public var nilEncodingStrategy: NilEncodingStrategy = .default + + /// Initializes `self` with default strategies. public init () { self.userInfo = [:] @@ -67,6 +71,21 @@ public struct KeyValueEncoder: Sendable { /// A key encoding strategy that doesn’t change key names during encoding. case useDefaultKeys } + + public enum DateEncodingStrategy: Sendable { + + /// Encodes dates by directly casting to Any. + case date + + /// Encodes dates from ISO8601 strings. + case iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) + + /// Encodes dates to Int in terms of milliseconds since midnight UTC on January 1, 1970. + case millisecondsSince1970 + + /// Encodes dates to Int in terms of seconds since midnight UTC on January 1, 1970. + case secondsSince1970 + } } /// Strategy used to encode and decode nil values. @@ -96,6 +115,12 @@ extension KeyValueEncoder: TopLevelEncoder { extension KeyValueEncoder { + struct EncodingStrategy { + var optionals: NilEncodingStrategy + var keys: KeyEncodingStrategy + var dates: DateEncodingStrategy + } + static func makePlistCompatible() -> KeyValueEncoder { var encoder = KeyValueEncoder() encoder.nilEncodingStrategy = .stringNull @@ -161,15 +186,11 @@ private extension KeyValueEncoder.NilEncodingStrategy { private extension KeyValueEncoder { - struct EncodingStrategy { - var optionals: NilEncodingStrategy - var keys: KeyEncodingStrategy - } - var strategy: EncodingStrategy { EncodingStrategy( optionals: nilEncodingStrategy, - keys: keyEncodingStrategy + keys: keyEncodingStrategy, + dates: dateEncodingStrategy ) } @@ -217,7 +238,7 @@ private extension KeyValueEncoder { } func encodeToValue(_ value: T) throws -> EncodedValue where T: Encodable { - guard let encoded = EncodedValue(value) else { + guard let encoded = EncodedValue.makeValue(for: value, using: strategy) else { try value.encode(to: self) return try getEncodedValue() } @@ -317,7 +338,7 @@ private extension KeyValueEncoder { } func encode(_ value: T, forKey key: Key) throws { - if let val = EncodedValue(value) { + if let val = EncodedValue.makeValue(for: value, using: strategy) { setValue(val, forKey: key) return } @@ -447,7 +468,7 @@ private extension KeyValueEncoder { } func encode(_ value: T) throws { - if let val = EncodedValue(value) { + if let val = EncodedValue.makeValue(for: value, using: strategy) { appendValue(val) return } @@ -566,7 +587,7 @@ private extension KeyValueEncoder { } func encode(_ value: T) throws where T: Encodable { - if let encoded = EncodedValue(value) { + if let encoded = EncodedValue.makeValue(for: value, using: strategy) { self.value = encoded return } @@ -687,20 +708,32 @@ struct AnyCodingKey: CodingKey { extension KeyValueEncoder.EncodedValue { - static func isSupportedValue(_ value: Any) -> Bool { - switch value { - case is Data: return true - case is Date: return true - case is URL: return true - case is Decimal: return true - default: return false + static func makeValue(for value: Any, using strategy: KeyValueEncoder.EncodingStrategy) -> Self? { + if let dataValue = value as? Data { + return .value(dataValue) + } else if let dateValue = value as? Date { + return makeValue(for: dateValue, using: strategy.dates) + } else if let urlValue = value as? URL { + return .value(urlValue) + } else if let decimalValue = value as? Decimal { + return .value(decimalValue) + } else { + return nil } } - init?(_ value: Any) { - guard Self.isSupportedValue(value) else { - return nil + static func makeValue(for date: Date, using strategy: KeyValueEncoder.DateEncodingStrategy) -> Self? { + switch strategy { + case .date: + return .value(date) + case .iso8601(options: let options): + let f = ISO8601DateFormatter() + f.formatOptions = options + return .value(f.string(from: date)) + case .millisecondsSince1970: + return .value(Int(date.timeIntervalSince1970 * 1000)) + case .secondsSince1970: + return .value(Int(date.timeIntervalSince1970)) } - self = .value(value) } } diff --git a/Tests/KeyValueEncoderTests.swift b/Tests/KeyValueEncoderTests.swift index d0ba418..87d7e94 100644 --- a/Tests/KeyValueEncoderTests.swift +++ b/Tests/KeyValueEncoderTests.swift @@ -87,7 +87,7 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder.encodeSingleValue { try $0.encode(URL(string: "fish.com")!) - } == EncodedValue(URL(string: "fish.com")!) + } == .value(URL(string: "fish.com")!) ) } @@ -202,10 +202,10 @@ struct KeyValueEncodedTests { @Test func encodes() throws { let node = Node(id: 1, - name: "root", - descendents: [Node(id: 2), Node(id: 3)], - related: ["left": Node(id: 4, descendents: [Node(id: 5)]), - "right": Node(id: 6)] + name: "root", + descendents: [Node(id: 2), Node(id: 3)], + related: ["left": Node(id: 4, descendents: [Node(id: 5)]), + "right": Node(id: 6)] ) #expect( @@ -245,9 +245,9 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder().encode(real) as? NSDictionary == [ - "tBool": true, - "tArray": [["tBool": false]] - ] + "tBool": true, + "tArray": [["tBool": false]] + ] ) } @@ -260,9 +260,9 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder().encode(real) as? NSDictionary == [ - "tDouble": 20, - "tFloat": -10 - ] + "tDouble": 20, + "tFloat": -10 + ] ) } @@ -274,8 +274,8 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder().encode(urls) as? NSDictionary == [ - "tURL": URL(string: "fish.com")! - ] + "tURL": URL(string: "fish.com")! + ] ) } @@ -293,14 +293,14 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder().encode(ints) as? NSDictionary == [ - "tInt": 10, - "tInt8": -20, - "tInt16": 30, - "tInt32": -40, - "tInt64": Int64.max, - "tArray": [["tInt": -1], ["tInt": -2]], - "tDictionary": ["rel": ["tInt": -3]] - ] + "tInt": 10, + "tInt8": -20, + "tInt16": 30, + "tInt32": -40, + "tInt64": Int64.max, + "tArray": [["tInt": -1], ["tInt": -2]], + "tDictionary": ["rel": ["tInt": -3]] + ] ) } @@ -318,14 +318,14 @@ struct KeyValueEncodedTests { #expect( try KeyValueEncoder().encode(uints) as? NSDictionary == [ - "tUInt": 10, - "tUInt8": 20, - "tUInt16": 30, - "tUInt32": 40, - "tUInt64": UInt64.max, - "tArray": [["tUInt": 50], ["tUInt": 60]], - "tDictionary": ["rel": ["tUInt": 70]] - ] + "tUInt": 10, + "tUInt8": 20, + "tUInt16": 30, + "tUInt32": 40, + "tUInt64": UInt64.max, + "tArray": [["tUInt": 50], ["tUInt": 60]], + "tDictionary": ["rel": ["tUInt": 70]] + ] ) } @@ -654,7 +654,7 @@ struct KeyValueEncodedTests { ) } - #if !os(WASI) +#if !os(WASI) @Test func plistCompatibleEncoder() throws { let keyValueAny = try KeyValueEncoder.makePlistCompatible().encode([1, 2, Int?.none, 4]) @@ -664,13 +664,36 @@ struct KeyValueEncodedTests { ] ) } - #endif +#endif @Test func encoder_Encodes_Dates() throws { - let date = Date() + var encoder = KeyValueEncoder() + let referenceDate = Date(timeIntervalSinceReferenceDate: 0) + + encoder.dateEncodingStrategy = .date + #expect( + try encoder.encode(referenceDate) as? Date == referenceDate + ) + + encoder.dateEncodingStrategy = .iso8601() + #expect( + try encoder.encode(referenceDate) as? String == "2001-01-01T00:00:00Z" + ) + + encoder.dateEncodingStrategy = .iso8601(options: [.withInternetDateTime, .withFractionalSeconds]) #expect( - try KeyValueEncoder().encode(date) as? Date == date + try encoder.encode(referenceDate) as? String == "2001-01-01T00:00:00.000Z" + ) + + encoder.dateEncodingStrategy = .millisecondsSince1970 + #expect( + try encoder.encode(referenceDate) as? Int == 978307200000 + ) + + encoder.dateEncodingStrategy = .secondsSince1970 + #expect( + try encoder.encode(referenceDate) as? Int == 978307200 ) } @@ -806,6 +829,20 @@ extension KeyValueEncoder.EncodedValue { } } +private extension KeyValueEncoder.EncodedValue { + static func isSupportedValue(_ value: Any) -> Bool { + Self.makeValue(for: value, using: .default) != nil + } +} + +private extension KeyValueEncoder.EncodingStrategy { + static let `default` = Self( + optionals: .default, + keys: .useDefaultKeys, + dates: .date + ) +} + #if !os(WASI) private extension PropertyListDecoder { static func decodeAny(_ type: T.Type, from value: Any?) throws -> T { diff --git a/Tests/KeyValueEncoderXCTests.swift b/Tests/KeyValueEncoderXCTests.swift index 856c584..aabb37b 100644 --- a/Tests/KeyValueEncoderXCTests.swift +++ b/Tests/KeyValueEncoderXCTests.swift @@ -87,7 +87,7 @@ final class KeyValueEncodedXCTests: XCTestCase { try KeyValueEncoder.encodeSingleValue { try $0.encode(URL(string: "fish.com")!) }, - EncodedValue(URL(string: "fish.com")!) + .value(URL(string: "fish.com")!) ) } @@ -767,6 +767,20 @@ extension KeyValueEncoder.EncodedValue { } } +private extension KeyValueEncoder.EncodedValue { + static func isSupportedValue(_ value: Any) -> Bool { + Self.makeValue(for: value, using: .default) != nil + } +} + +private extension KeyValueEncoder.EncodingStrategy { + static let `default` = Self( + optionals: .default, + keys: .useDefaultKeys, + dates: .date + ) +} + #if !os(WASI) private extension PropertyListDecoder { static func decodeAny(_ type: T.Type, from value: Any?) throws -> T { From 9790cea2eed779ba22379f85eb1cc9e40814d64d Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sun, 20 Jul 2025 14:00:09 +1000 Subject: [PATCH 2/3] DateDecodingStrategy --- Sources/KeyValueDecoder.swift | 44 +++++++++++++++++++++++++++++--- Tests/KeyValueDecoderTests.swift | 30 +++++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Sources/KeyValueDecoder.swift b/Sources/KeyValueDecoder.swift index a60f9b6..bf49910 100644 --- a/Sources/KeyValueDecoder.swift +++ b/Sources/KeyValueDecoder.swift @@ -38,8 +38,8 @@ public struct KeyValueDecoder: Sendable { /// Contextual user-provided information for use during encoding. public var userInfo: [CodingUserInfoKey: any Sendable] - /// The strategy to use for decoding `nil`. Defaults to `Optional.none` which can be decoded to any optional type. - public var nilDecodingStrategy: NilDecodingStrategy = .default + /// The strategy to use for decoding Date types. + public var dateDecodingStrategy: DateDecodingStrategy = .date /// The strategy to use for decoding BinaryInteger types. Defaults to `.exact` for lossless conversion between types. public var intDecodingStrategy: IntDecodingStrategy = .exact @@ -47,6 +47,9 @@ public struct KeyValueDecoder: Sendable { /// The strategy to use for decoding each types keys. public var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys + /// The strategy to use for decoding `nil`. Defaults to `Optional.none` which can be decoded to any optional type. + public var nilDecodingStrategy: NilDecodingStrategy = .default + /// Initializes `self` with default strategy. public init () { self.userInfo = [:] @@ -94,6 +97,21 @@ public struct KeyValueDecoder: Sendable { /// A key encoding strategy that doesn’t change key names during encoding. case useDefaultKeys } + + public enum DateDecodingStrategy: Sendable { + + /// Decodes dates by casting from Any. + case date + + /// Decodes dates from ISO8601 strings. + case iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) + + /// Decodes dates in terms of milliseconds since midnight UTC on January 1st, 1970. + case millisecondsSince1970 + + /// Decodes dates in terms of seconds since midnight UTC on January 1st, 1970. + case secondsSince1970 + } } #if canImport(Combine) @@ -118,13 +136,15 @@ private extension KeyValueDecoder { var optionals: NilDecodingStrategy var integers: IntDecodingStrategy var keys: KeyDecodingStrategy + var dates: DateDecodingStrategy } var strategy: DecodingStrategy { DecodingStrategy( optionals: nilDecodingStrategy, integers: intDecodingStrategy, - keys: keyDecodingStrategy + keys: keyDecodingStrategy, + dates: dateDecodingStrategy ) } @@ -336,7 +356,23 @@ private extension KeyValueDecoder { } func decode(_ type: Date.Type) throws -> Date { - try getValue() + switch strategy.dates { + case .date: + return try getValue() + case .iso8601(options: let options): + let string = try decode(String.self) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = options + guard let date = formatter.date(from: string) else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: codingPath, debugDescription: "Failed to decode Date from ISO8601 string \(string)")) + } + return date + case .millisecondsSince1970: + return try Date(timeIntervalSince1970: TimeInterval(decode(Int.self)) / 1000) + + case .secondsSince1970: + return try Date(timeIntervalSince1970: TimeInterval(decode(Int.self))) + } } func decode(_ type: Data.Type) throws -> Data { diff --git a/Tests/KeyValueDecoderTests.swift b/Tests/KeyValueDecoderTests.swift index 088e4c9..09e94a7 100644 --- a/Tests/KeyValueDecoderTests.swift +++ b/Tests/KeyValueDecoderTests.swift @@ -403,11 +403,35 @@ struct KeyValueDecoderTests { @Test func decodes_Date() throws { - let decoder = KeyValueDecoder() + var decoder = KeyValueDecoder() + let referenceDate = Date(timeIntervalSinceReferenceDate: 0) + + decoder.dateDecodingStrategy = .date + #expect( + try decoder.decode(Date.self, from: referenceDate) == referenceDate + ) + + decoder.dateDecodingStrategy = .iso8601() + #expect( + try decoder.decode(Date.self, from: "2001-01-01T00:00:00Z") == referenceDate + ) + #expect(throws: DecodingError.self) { + try decoder.decode(Date.self, from: "2001-01-01") + } + + decoder.dateDecodingStrategy = .iso8601(options: [.withInternetDateTime, .withFractionalSeconds]) + #expect( + try decoder.decode(Date.self, from: "2001-01-01T00:00:00.000Z") == referenceDate + ) + + decoder.dateDecodingStrategy = .millisecondsSince1970 + #expect( + try decoder.decode(Date.self, from: 978307200000) == referenceDate + ) - let date = Date(timeIntervalSinceReferenceDate: 0) + decoder.dateDecodingStrategy = .secondsSince1970 #expect( - try decoder.decode(Date.self, from: date) == date + try decoder.decode(Date.self, from: 978307200) == referenceDate ) } From c584af06b4e0868cd1c8122106b84d2cdcb34095 Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Sun, 20 Jul 2025 14:00:15 +1000 Subject: [PATCH 3/3] README --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index fd49c91..8f0da7f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,34 @@ let user = try KeyValueDecoder().decode(User.self, from: [["id": 1, "name": "Her let ascii = try KeyValueDecoder().decode([UInt8].self, from: [10, 100, 1000]) ``` + +## Date Encoding/Decoding Strategy + +The encoding of `Date` can be adjusted by setting the strategy. + +By default `Date` instances are encoded by simply casting to `Any` but this adjusted by setting the strategy. + +The default strategy casts to `Any` leaving the instance unchanged: + +```swift +var encoder = KeyValueEncoder() +encoder.dateEncodingStrategy = .date + +// Date() +let any = try encoder.encode(Date()) +``` + +ISO8601 compatible strings can be used: + +```swift +encoder.dateEncodingStrategy = .iso8601() + +// "1970-01-01T00:00:00Z" +let any = try encoder.encode(Date(timeIntervalSince1970: 0)) +``` + +Epochs are also supported using `.secondsSince1970` and `millisecondsSince1970`. + ## Nil Encoding/Decoding Strategy The encoding of `Optional.none` can be adjusted by setting the strategy.