From e5d2a11b25d4196dcd9bafc33575e39932ef256e Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:18:51 +0200 Subject: [PATCH 01/20] Fix SwiftLint warning --- Sources/Encoder/DictionaryComponentEncoder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index a6f4627..4389de7 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -250,6 +250,6 @@ extension EncodingError { Use DictionaryNonConformingFloatEncodingStrategy.convertToString to specify how the value should be encoded. """ - return .invalidValue(value, EncodingError.Context(codingPath: codingPath, debugDescription: debugDescription)) + return .invalidValue(value, Context(codingPath: codingPath, debugDescription: debugDescription)) } } From 93bec1a88bde2f856f88504a16fea2990ca7c234 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 01:44:42 +0200 Subject: [PATCH 02/20] Fix cursor handling in unkeyed decoding container --- .../DictionaryUnkeyedDecodingContainer.swift | 76 +++++++----- Tests/Decoder/DictionaryDecoderTests.swift | 113 ++++++++++++++++++ 2 files changed, 160 insertions(+), 29 deletions(-) diff --git a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift index f7abb78..f7b2d11 100644 --- a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift @@ -41,7 +41,8 @@ internal final class DictionaryUnkeyedDecodingContainer: // MARK: - Instance Methods - private func popNextComponent() throws -> Any? { + @inline(__always) + private func peekNextComponent() throws -> Any? { guard currentIndex < components.count else { let errorContext = DecodingError.Context( codingPath: currentCodingPath, @@ -51,95 +52,112 @@ internal final class DictionaryUnkeyedDecodingContainer: throw DecodingError.valueNotFound(Any.self, errorContext) } - defer { - currentIndex += 1 - } - return components[currentIndex] } + @inline(__always) + private func decodeNextComponent( + _ decodeComponent: (_ component: Any?, _ codingPath: [CodingKey]) throws -> T + ) throws -> T { + let value = try decodeComponent(try peekNextComponent(), currentCodingPath) + + currentIndex += 1 + + return value + } + + private func superDecoder(for component: Any?, at codingPath: [CodingKey]) -> Decoder { + DictionarySingleValueDecodingContainer( + component: component, + options: options, + userInfo: userInfo, + codingPath: codingPath + ) + } + // MARK: - UnkeyedDecodingContainer internal func decodeNil() throws -> Bool { - decodeNilComponent(from: try popNextComponent()) + guard decodeNilComponent(from: try peekNextComponent()) else { + return false + } + + currentIndex += 1 + + return true } internal func decode(_ type: Bool.Type) throws -> Bool { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Int.Type) throws -> Int { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Int8.Type) throws -> Int8 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Int16.Type) throws -> Int16 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Int32.Type) throws -> Int32 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Int64.Type) throws -> Int64 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: UInt.Type) throws -> UInt { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: UInt8.Type) throws -> UInt8 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: UInt16.Type) throws -> UInt16 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: UInt32.Type) throws -> UInt32 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: UInt64.Type) throws -> UInt64 { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Double.Type) throws -> Double { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: Float.Type) throws -> Float { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: String.Type) throws -> String { - try decodeComponentValue(from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } internal func decode(_ type: T.Type) throws -> T { - try decodeComponentValue(of: type, from: try popNextComponent(), at: currentCodingPath) + try decodeNextComponent { try decodeComponentValue(of: type, from: $0, at: $1) } } internal func nestedContainer( keyedBy keyType: NestedKey.Type ) throws -> KeyedDecodingContainer { - try superDecoder().container(keyedBy: keyType) + try decodeNextComponent { try superDecoder(for: $0, at: $1).container(keyedBy: keyType) } } internal func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer { - try superDecoder().unkeyedContainer() + try decodeNextComponent { try superDecoder(for: $0, at: $1).unkeyedContainer() } } internal func superDecoder() throws -> Decoder { - DictionarySingleValueDecodingContainer( - component: try popNextComponent(), - options: options, - userInfo: userInfo, - codingPath: currentCodingPath - ) + try decodeNextComponent { superDecoder(for: $0, at: $1) } } } diff --git a/Tests/Decoder/DictionaryDecoderTests.swift b/Tests/Decoder/DictionaryDecoderTests.swift index 5228352..df95478 100644 --- a/Tests/Decoder/DictionaryDecoderTests.swift +++ b/Tests/Decoder/DictionaryDecoderTests.swift @@ -333,6 +333,88 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingStructUsingDecodeIfPresentOfUnkeyedContainer() { + struct DecodableStruct: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case foo + } + + let bar: Int? + let baz: Int? + let qux: Int? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .foo) + + bar = try unkeyedContainer.decodeIfPresent(Int.self) + baz = try unkeyedContainer.decodeIfPresent(Int.self) + qux = try unkeyedContainer.decodeIfPresent(Int.self) + } + } + + let dictionary: [String: Any] = ["foo": [123, NSNull(), 456] as [Any]] + + assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) + } + + func testThatDecoderSucceedsWhenDecodingElementCodingPathsOfUnkeyedContainer() { + struct Element: Decodable, Equatable { + let index: Int? + + init(from decoder: Decoder) throws { + index = decoder.codingPath.last?.intValue + } + } + + struct DecodableStruct: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case foo + } + + let bar: Element + let baz: Element + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .foo) + + bar = try unkeyedContainer.decode(Element.self) + baz = try Element(from: unkeyedContainer.superDecoder()) + } + } + + let dictionary = ["foo": [123, 456]] + + assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) + } + + func testThatDecoderSucceedsWhenDecodingStructAfterFailedAttemptsInUnkeyedContainer() { + struct DecodableStruct: Decodable, Equatable { + enum CodingKeys: String, CodingKey { + case foo + } + + let bar: String + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .foo) + + _ = try? unkeyedContainer.decode(Int.self) + _ = try? unkeyedContainer.decode([Int].self) + _ = try? unkeyedContainer.nestedContainer(keyedBy: CodingKeys.self) + _ = try? unkeyedContainer.nestedUnkeyedContainer() + + bar = try unkeyedContainer.decode(String.self) + } + } + + let dictionary = ["foo": ["bar"]] + + assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) + } + func testThatDecoderSucceedsWhenDecodingStructInSeparateKeyedContainersOfUnkeyedContainer() { struct DecodableStruct: Decodable, Equatable { enum CodingKeys: String, CodingKey { @@ -735,6 +817,37 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } } + func testThatDecoderFailsWhenDecodingInvalidElementForUnkeyedContainer() { + struct DecodableStruct: Decodable { + enum CodingKeys: String, CodingKey { + case foo + } + + let bar: Int + let baz: Int + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .foo) + + bar = try unkeyedContainer.decode(Int.self) + baz = try unkeyedContainer.decode(Int.self) + } + } + + let dictionary: [String: Any] = ["foo": [123, "456"] as [Any]] + + assertDecoderFails(decoding: DecodableStruct.self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, context) where type is Int.Type: + return context.codingPath.map(\.stringValue) == ["foo", "1"] + + default: + return false + } + } + } + // MARK: - XCTestCase override func setUp() { From 79d3b5be7e824f53c7d39430e0e8600ef0fe9590 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 02:43:30 +0200 Subject: [PATCH 03/20] Resolve colliding custom keys deterministically --- Sources/Decoder/DictionaryKeyedDecodingContainer.swift | 10 +++++----- .../Options/DictionaryKeyDecodingStrategy.swift | 3 +++ Tests/Decoder/DictionaryDecoderStrategiesTests.swift | 9 +++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index 68b89bf..d55086f 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -27,12 +27,12 @@ internal class DictionaryKeyedDecodingContainer: case .useDefaultKeys: self.components = components - case let.custom(closure): - let componentKeysAndValues = components.map { key, value in - (closure(codingPath.appending(AnyCodingKey(key))).stringValue, value) - } + case let .custom(closure): + let componentKeysAndValues = components + .sorted { $0.key < $1.key } + .map { key, value in (closure(codingPath.appending(AnyCodingKey(key))).stringValue, value) } - self.components = Dictionary(componentKeysAndValues) { $1 } + self.components = Dictionary(componentKeysAndValues) { first, _ in first } } self.options = options diff --git a/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift index b036eef..aa3870c 100644 --- a/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift +++ b/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift @@ -9,5 +9,8 @@ public enum DictionaryKeyDecodingStrategy: Sendable { case useDefaultKeys /// A key decoding strategy defined by the closure you supply. + /// + /// If several keys are converted to the same key, + /// the value of the lexicographically smallest original key is used. case custom(@Sendable (_ codingPath: [CodingKey]) -> CodingKey) } diff --git a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift index fffb9ba..9916dea 100644 --- a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift +++ b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift @@ -48,6 +48,15 @@ final class DictionaryDecoderStrategiesTests: XCTestCase, DictionaryDecoderTesti assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingCollidingKeysUsingCustomFunctionForKeys() { + decoder.keyDecodingStrategy = .custom { _ in AnyCodingKey("foobar") } + + let letters = "abcdefghijklmnopqrstuvwxyz".map(String.init) + let dictionary = Dictionary(uniqueKeysWithValues: letters.enumerated().map { ($1, $0) }) + + assertDecoderSucceeds(decoding: ["foobar": 0], from: dictionary) + } + // MARK: - func testThatDecoderSucceedsWhenDecodingDate() { From 39c38f076caaba6d7d04edafa80ae083d32b97d6 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:18:43 +0200 Subject: [PATCH 04/20] Remove unused imports --- Sources/Decoder/DictionaryKeyedDecodingContainer.swift | 2 -- Sources/Decoder/DictionarySingleValueDecodingContainer.swift | 2 -- Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift | 2 -- Sources/Decoder/Options/DictionaryDecodingOptions.swift | 2 -- Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift | 2 -- .../Options/DictionaryNonConformingFloatDecodingStrategy.swift | 2 -- Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift | 2 -- Sources/Encoder/DictionaryComponent.swift | 2 -- Sources/Encoder/DictionaryComponentContainer.swift | 2 -- Sources/Encoder/DictionaryKeyedEncodingContainer.swift | 2 -- Sources/Encoder/DictionarySingleValueEncodingContainer.swift | 2 -- Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift | 2 -- Sources/Encoder/Options/DictionaryEncodingOptions.swift | 2 -- Sources/Encoder/Options/DictionaryKeyEncodingStrategy.swift | 2 -- Sources/Encoder/Options/DictionaryNilEncodingStrategy.swift | 2 -- .../Options/DictionaryNonConformingFloatEncodingStrategy.swift | 2 -- Sources/Tools/AnyCodingKey.swift | 2 -- Sources/Tools/Mutex/Mutex.swift | 2 -- Sources/Tools/Mutex/MutexStorage.swift | 2 -- Sources/Tools/Optional+Extensions.swift | 2 -- Sources/Tools/RangeReplaceableCollection+Extensions.swift | 2 -- 21 files changed, 42 deletions(-) diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index d55086f..886b4de 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal class DictionaryKeyedDecodingContainer: KeyedDecodingContainerProtocol, DictionaryComponentDecoder { diff --git a/Sources/Decoder/DictionarySingleValueDecodingContainer.swift b/Sources/Decoder/DictionarySingleValueDecodingContainer.swift index 6a4f08b..238fe51 100644 --- a/Sources/Decoder/DictionarySingleValueDecodingContainer.swift +++ b/Sources/Decoder/DictionarySingleValueDecodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionarySingleValueDecodingContainer: Decoder, SingleValueDecodingContainer, diff --git a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift index f7b2d11..fd70d03 100644 --- a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionaryUnkeyedDecodingContainer: UnkeyedDecodingContainer, DictionaryComponentDecoder { diff --git a/Sources/Decoder/Options/DictionaryDecodingOptions.swift b/Sources/Decoder/Options/DictionaryDecodingOptions.swift index fa2a51f..5c8a3d1 100644 --- a/Sources/Decoder/Options/DictionaryDecodingOptions.swift +++ b/Sources/Decoder/Options/DictionaryDecodingOptions.swift @@ -1,5 +1,3 @@ -import Foundation - internal struct DictionaryDecodingOptions { // MARK: - Instance Properties diff --git a/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift index aa3870c..f2e1dc1 100644 --- a/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift +++ b/Sources/Decoder/Options/DictionaryKeyDecodingStrategy.swift @@ -1,5 +1,3 @@ -import Foundation - /// The values that determine how to decode a type’s coding keys from Dictionary keys. public enum DictionaryKeyDecodingStrategy: Sendable { diff --git a/Sources/Decoder/Options/DictionaryNonConformingFloatDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryNonConformingFloatDecodingStrategy.swift index a3dd669..3c2a59c 100644 --- a/Sources/Decoder/Options/DictionaryNonConformingFloatDecodingStrategy.swift +++ b/Sources/Decoder/Options/DictionaryNonConformingFloatDecodingStrategy.swift @@ -1,5 +1,3 @@ -import Foundation - /// The strategies for encoding nonconforming floating-point numbers, /// also known as IEEE 754 exceptional values. public enum DictionaryNonConformingFloatDecodingStrategy: Sendable { diff --git a/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift b/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift index 9a4410f..cd99768 100644 --- a/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionaryAnyKeyedEncodingContainer: DictionaryComponentContainer { // MARK: - Instance Properties diff --git a/Sources/Encoder/DictionaryComponent.swift b/Sources/Encoder/DictionaryComponent.swift index 138d310..b1f1a41 100644 --- a/Sources/Encoder/DictionaryComponent.swift +++ b/Sources/Encoder/DictionaryComponent.swift @@ -1,5 +1,3 @@ -import Foundation - internal indirect enum DictionaryComponent { // MARK: - Enumeration Cases diff --git a/Sources/Encoder/DictionaryComponentContainer.swift b/Sources/Encoder/DictionaryComponentContainer.swift index 5fea329..c373861 100644 --- a/Sources/Encoder/DictionaryComponentContainer.swift +++ b/Sources/Encoder/DictionaryComponentContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal protocol DictionaryComponentContainer { // MARK: - Instance Methods diff --git a/Sources/Encoder/DictionaryKeyedEncodingContainer.swift b/Sources/Encoder/DictionaryKeyedEncodingContainer.swift index df841d6..874e3a1 100644 --- a/Sources/Encoder/DictionaryKeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryKeyedEncodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionaryKeyedEncodingContainer: KeyedEncodingContainerProtocol, DictionaryComponentEncoder { diff --git a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift index d9d6741..99237bc 100644 --- a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift +++ b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionarySingleValueEncodingContainer: Encoder, SingleValueEncodingContainer, diff --git a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift index 842b89e..34fac91 100644 --- a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift @@ -1,5 +1,3 @@ -import Foundation - internal final class DictionaryUnkeyedEncodingContainer: UnkeyedEncodingContainer, DictionaryComponentContainer, diff --git a/Sources/Encoder/Options/DictionaryEncodingOptions.swift b/Sources/Encoder/Options/DictionaryEncodingOptions.swift index 6878ada..c4dd528 100644 --- a/Sources/Encoder/Options/DictionaryEncodingOptions.swift +++ b/Sources/Encoder/Options/DictionaryEncodingOptions.swift @@ -1,5 +1,3 @@ -import Foundation - internal struct DictionaryEncodingOptions { // MARK: - Instance Properties diff --git a/Sources/Encoder/Options/DictionaryKeyEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryKeyEncodingStrategy.swift index da26126..deb3215 100644 --- a/Sources/Encoder/Options/DictionaryKeyEncodingStrategy.swift +++ b/Sources/Encoder/Options/DictionaryKeyEncodingStrategy.swift @@ -1,5 +1,3 @@ -import Foundation - public enum DictionaryKeyEncodingStrategy: Sendable { // MARK: - Enumeration Cases diff --git a/Sources/Encoder/Options/DictionaryNilEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryNilEncodingStrategy.swift index 37abb2a..b321d17 100644 --- a/Sources/Encoder/Options/DictionaryNilEncodingStrategy.swift +++ b/Sources/Encoder/Options/DictionaryNilEncodingStrategy.swift @@ -1,5 +1,3 @@ -import Foundation - public enum DictionaryNilEncodingStrategy: Sendable { // MARK: - Enumeration Cases diff --git a/Sources/Encoder/Options/DictionaryNonConformingFloatEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryNonConformingFloatEncodingStrategy.swift index 63cffcd..bff87ba 100644 --- a/Sources/Encoder/Options/DictionaryNonConformingFloatEncodingStrategy.swift +++ b/Sources/Encoder/Options/DictionaryNonConformingFloatEncodingStrategy.swift @@ -1,5 +1,3 @@ -import Foundation - public enum DictionaryNonConformingFloatEncodingStrategy: Sendable { // MARK: - Enumeration Cases diff --git a/Sources/Tools/AnyCodingKey.swift b/Sources/Tools/AnyCodingKey.swift index b883a4e..1aa1f95 100644 --- a/Sources/Tools/AnyCodingKey.swift +++ b/Sources/Tools/AnyCodingKey.swift @@ -1,5 +1,3 @@ -import Foundation - internal struct AnyCodingKey: CodingKey { // MARK: - Type Properties diff --git a/Sources/Tools/Mutex/Mutex.swift b/Sources/Tools/Mutex/Mutex.swift index 1f3bcb2..9413be2 100644 --- a/Sources/Tools/Mutex/Mutex.swift +++ b/Sources/Tools/Mutex/Mutex.swift @@ -1,5 +1,3 @@ -import Foundation - public struct Mutex: @unchecked Sendable { private let storage: MutexStorage diff --git a/Sources/Tools/Mutex/MutexStorage.swift b/Sources/Tools/Mutex/MutexStorage.swift index a884dad..0602e2d 100644 --- a/Sources/Tools/Mutex/MutexStorage.swift +++ b/Sources/Tools/Mutex/MutexStorage.swift @@ -1,5 +1,3 @@ -import Foundation - import struct os.os_unfair_lock_t import struct os.os_unfair_lock diff --git a/Sources/Tools/Optional+Extensions.swift b/Sources/Tools/Optional+Extensions.swift index 4344e37..2d01ead 100644 --- a/Sources/Tools/Optional+Extensions.swift +++ b/Sources/Tools/Optional+Extensions.swift @@ -1,5 +1,3 @@ -import Foundation - extension Optional { // MARK: - Instance Properties diff --git a/Sources/Tools/RangeReplaceableCollection+Extensions.swift b/Sources/Tools/RangeReplaceableCollection+Extensions.swift index a68dfd6..06c51f1 100644 --- a/Sources/Tools/RangeReplaceableCollection+Extensions.swift +++ b/Sources/Tools/RangeReplaceableCollection+Extensions.swift @@ -1,5 +1,3 @@ -import Foundation - extension RangeReplaceableCollection { // MARK: - Instance Methods From 715595425c3adddf4ab1c97ec0781c2f40db2682 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:18:51 +0200 Subject: [PATCH 05/20] Remove unused isEmptyOrNil --- Sources/Tools/Optional+Extensions.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Sources/Tools/Optional+Extensions.swift b/Sources/Tools/Optional+Extensions.swift index 2d01ead..141c9ac 100644 --- a/Sources/Tools/Optional+Extensions.swift +++ b/Sources/Tools/Optional+Extensions.swift @@ -6,12 +6,3 @@ extension Optional { self == nil } } - -extension Optional where Wrapped: Collection { - - // MARK: - Instance Properties - - internal var isEmptyOrNil: Bool { - self?.isEmpty ?? true - } -} From e1080dcf4047ab4219c090e53d4f4b79ec50982c Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:19:22 +0200 Subject: [PATCH 06/20] Share the setup of root encoders and decoders --- Sources/Decoder/DictionaryDecoder.swift | 25 +++++++----------- Sources/Encoder/DictionaryEncoder.swift | 34 ++++++++++--------------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/Sources/Decoder/DictionaryDecoder.swift b/Sources/Decoder/DictionaryDecoder.swift index f7e4173..0e86f0c 100644 --- a/Sources/Decoder/DictionaryDecoder.swift +++ b/Sources/Decoder/DictionaryDecoder.swift @@ -54,20 +54,22 @@ public final class DictionaryDecoder: Sendable { // MARK: - Instance Methods - public func decode( - _ type: T.Type = T.self, - from dictionary: [String: Any] - ) throws -> T { + private func rootDecoder(for dictionary: [String: Any]) -> DictionarySingleValueDecodingContainer { let options = optionsMutex.withLock { $0 } - let decoder = DictionarySingleValueDecodingContainer( + return DictionarySingleValueDecodingContainer( component: dictionary, options: options, userInfo: userInfo, codingPath: [] ) + } - return try T(from: decoder) + public func decode( + _ type: T.Type = T.self, + from dictionary: [String: Any] + ) throws -> T { + try T(from: rootDecoder(for: dictionary)) } public func decode(from dictionary: [String: Any]) throws -> T { @@ -80,15 +82,6 @@ public final class DictionaryDecoder: Sendable { from dictionary: [String: Any], configuration: T.DecodingConfiguration ) throws -> T { - let options = optionsMutex.withLock { $0 } - - let decoder = DictionarySingleValueDecodingContainer( - component: dictionary, - options: options, - userInfo: userInfo, - codingPath: [] - ) - - return try T(from: decoder, configuration: configuration) + try T(from: rootDecoder(for: dictionary), configuration: configuration) } } diff --git a/Sources/Encoder/DictionaryEncoder.swift b/Sources/Encoder/DictionaryEncoder.swift index d424b99..3d90275 100644 --- a/Sources/Encoder/DictionaryEncoder.swift +++ b/Sources/Encoder/DictionaryEncoder.swift @@ -61,7 +61,10 @@ public final class DictionaryEncoder: Sendable { // MARK: - Instance Methods - public func encode(_ value: T) throws -> [String: Sendable] { + private func encodeRootValue( + _ value: T, + encoding: (_ encoder: Encoder) throws -> Void + ) throws -> [String: Sendable] { let options = optionsMutex.withLock { $0 } let encoder = DictionarySingleValueEncodingContainer( @@ -70,7 +73,7 @@ public final class DictionaryEncoder: Sendable { codingPath: [] ) - try value.encode(to: encoder) + try encoding(encoder) guard let dictionary = encoder.resolveValue() as? [String: Sendable] else { let errorContext = EncodingError.Context( @@ -84,30 +87,19 @@ public final class DictionaryEncoder: Sendable { return dictionary } + public func encode(_ value: T) throws -> [String: Sendable] { + try encodeRootValue(value) { encoder in + try value.encode(to: encoder) + } + } + @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) public func encode( _ value: T, configuration: T.EncodingConfiguration ) throws -> [String: Sendable] { - let options = optionsMutex.withLock { $0 } - - let encoder = DictionarySingleValueEncodingContainer( - options: options, - userInfo: userInfo, - codingPath: [] - ) - - try value.encode(to: encoder, configuration: configuration) - - guard let dictionary = encoder.resolveValue() as? [String: Sendable] else { - let errorContext = EncodingError.Context( - codingPath: [], - debugDescription: "Root component cannot be encoded in Dictionary" - ) - - throw EncodingError.invalidValue(value, errorContext) + try encodeRootValue(value) { encoder in + try value.encode(to: encoder, configuration: configuration) } - - return dictionary } } From 3ef36838fdc72c86f80158a1c4bad41688ccc520 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:19:40 +0200 Subject: [PATCH 07/20] Remove availability checks below the deployment targets --- Sources/Decoder/DictionaryComponentDecoder.swift | 4 ---- Sources/Decoder/DictionaryDecoder.swift | 1 - Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift | 1 - Sources/Encoder/DictionaryComponentEncoder.swift | 4 ---- Sources/Encoder/DictionaryEncoder.swift | 1 - Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift | 1 - Tests/Decoder/DictionaryDecoderStrategiesTests.swift | 2 -- Tests/Decoder/DictionaryDecoderTesting.swift | 4 ---- Tests/Encoder/DictionaryEncoderStrategiesTests.swift | 2 -- Tests/Encoder/DictionaryEncoderTesting.swift | 4 ---- 10 files changed, 24 deletions(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index be18390..38a9c9c 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -103,10 +103,6 @@ extension DictionaryComponentDecoder { return Date(timeIntervalSince1970: try decodePrimitiveValue(from: component, at: codingPath) / 1000.0) case .iso8601: - guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { - fatalError("ISO8601DateFormatter is unavailable on this platform.") - } - let formattedDate = try decodePrimitiveValue(of: String.self, from: component, at: codingPath) guard let date = ISO8601DateFormatter().date(from: formattedDate) else { diff --git a/Sources/Decoder/DictionaryDecoder.swift b/Sources/Decoder/DictionaryDecoder.swift index 0e86f0c..f96ee3d 100644 --- a/Sources/Decoder/DictionaryDecoder.swift +++ b/Sources/Decoder/DictionaryDecoder.swift @@ -76,7 +76,6 @@ public final class DictionaryDecoder: Sendable { try decode(T.self, from: dictionary) } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) public func decode( _ type: T.Type = T.self, from dictionary: [String: Any], diff --git a/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift index 11b36fd..c0a6caf 100644 --- a/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift +++ b/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift @@ -15,7 +15,6 @@ public enum DictionaryDateDecodingStrategy: Sendable { case millisecondsSince1970 /// The strategy that formats dates according to the ISO 8601 standard. - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) case iso8601 /// The strategy that defers formatting settings to a supplied date formatter. diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 4389de7..1d2c655 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -72,10 +72,6 @@ extension DictionaryComponentEncoder { return encodePrimitiveValue(date.timeIntervalSince1970, at: codingPath) case .iso8601: - guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { - fatalError("ISO8601DateFormatter is unavailable on this platform.") - } - let formattedDate = ISO8601DateFormatter.string( from: date, timeZone: .iso8601TimeZone, diff --git a/Sources/Encoder/DictionaryEncoder.swift b/Sources/Encoder/DictionaryEncoder.swift index 3d90275..3ece945 100644 --- a/Sources/Encoder/DictionaryEncoder.swift +++ b/Sources/Encoder/DictionaryEncoder.swift @@ -93,7 +93,6 @@ public final class DictionaryEncoder: Sendable { } } - @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) public func encode( _ value: T, configuration: T.EncodingConfiguration diff --git a/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift index 92c06cf..f310280 100644 --- a/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift +++ b/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift @@ -9,7 +9,6 @@ public enum DictionaryDateEncodingStrategy: Sendable { case millisecondsSince1970 case secondsSince1970 - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) case iso8601 case formatted(DateFormatter) diff --git a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift index 9916dea..df9c07c 100644 --- a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift +++ b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift @@ -99,7 +99,6 @@ final class DictionaryDecoderStrategiesTests: XCTestCase, DictionaryDecoderTesti assertDecoderSucceeds(decoding: [String: Date].self, from: dictionary) } - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) func testThatDecoderSucceedsWhenDecodingDateFromISO8601Format() { decoder.dateDecodingStrategy = .iso8601 @@ -108,7 +107,6 @@ final class DictionaryDecoderStrategiesTests: XCTestCase, DictionaryDecoderTesti assertDecoderSucceeds(decoding: [String: Date].self, from: dictionary) } - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) func testThatDecoderFailsWhenDecodingInvalidDateFromISO8601Format() { decoder.dateDecodingStrategy = .iso8601 diff --git a/Tests/Decoder/DictionaryDecoderTesting.swift b/Tests/Decoder/DictionaryDecoderTesting.swift index 0e31868..3a68f74 100644 --- a/Tests/Decoder/DictionaryDecoderTesting.swift +++ b/Tests/Decoder/DictionaryDecoderTesting.swift @@ -153,10 +153,6 @@ extension DictionaryDateDecodingStrategy { return .secondsSince1970 case .iso8601: - guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { - fatalError("ISO8601DateFormatter is unavailable on this platform.") - } - return .iso8601 case let .formatted(dateFormatter): diff --git a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift index 4ec139d..5148404 100644 --- a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift +++ b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift @@ -60,7 +60,6 @@ final class DictionaryEncoderStrategiesTests: XCTestCase, DictionaryEncoderTesti assertEncoderSucceeds(encoding: value) } - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) func testThatEncoderSucceedsWhenEncodingDateToISO8601Format() { encoder.dateEncodingStrategy = .iso8601 @@ -69,7 +68,6 @@ final class DictionaryEncoderStrategiesTests: XCTestCase, DictionaryEncoderTesti assertEncoderSucceeds(encoding: value) } - @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) func testThatEncoderSucceedsWhenEncodingDateUsingFormatter() { let dateFormatter = DateFormatter() diff --git a/Tests/Encoder/DictionaryEncoderTesting.swift b/Tests/Encoder/DictionaryEncoderTesting.swift index ba17b03..28c2345 100644 --- a/Tests/Encoder/DictionaryEncoderTesting.swift +++ b/Tests/Encoder/DictionaryEncoderTesting.swift @@ -119,10 +119,6 @@ extension DictionaryDateEncodingStrategy { return .secondsSince1970 case .iso8601: - guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { - fatalError("ISO8601DateFormatter is unavailable on this platform.") - } - return .iso8601 case let .formatted(dateFormatter): From e8db020752d780e4043d509dc799ac2b44f58668 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:21:48 +0200 Subject: [PATCH 08/20] Decode numbers of other types like NSNumber --- .../Decoder/DictionaryComponentDecoder.swift | 36 ++++++++++++++- Tests/Decoder/DictionaryDecoderTests.swift | 46 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index 38a9c9c..db12f99 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -16,12 +16,34 @@ extension DictionaryComponentDecoder { at codingPath: [CodingKey] ) throws -> T { guard let value = component as? T else { + return try decodeConvertedNumber(from: component, at: codingPath) + } + + return value + } + + // Numbers of other types are converted the same way as `NSNumber`, + // so a dictionary decodes equally whether it holds Swift numbers or `NSNumber` instances. + // Unlike `NSNumber`, booleans are not converted to or from numbers here, as in `JSONDecoder`. + @inline(never) + private func decodeConvertedNumber( + from component: Any?, + at codingPath: [CodingKey] + ) throws -> T { + let number = component as? NSNumber + + guard let number, !isBoolean(number), !(T.self is Bool.Type), let value = number as? T else { throw DecodingError.invalidComponent(component, of: T.self, at: codingPath) } return value } + // Booleans are bridged to `NSNumber` too, so they are told apart by their Core Foundation type. + private func isBoolean(_ number: NSNumber) -> Bool { + CFGetTypeID(number) == CFBooleanGetTypeID() + } + private func decodeNonPrimitiveValue( of type: T.Type = T.self, from component: Any?, @@ -88,7 +110,19 @@ extension DictionaryComponentDecoder { break } - throw DecodingError.invalidComponent(component, of: T.self, at: codingPath) + // Numbers of other types are converted, which strings do not survive. + let number: T = try decodeConvertedNumber(from: component, at: codingPath) + + guard number.isFinite else { + let errorContext = DecodingError.Context( + codingPath: codingPath, + debugDescription: "Parsed dictionary number \(number) does not fit in \(T.self)." + ) + + throw DecodingError.dataCorrupted(errorContext) + } + + return number } private func decodeDate(from component: Any?, at codingPath: [CodingKey]) throws -> Date { diff --git a/Tests/Decoder/DictionaryDecoderTests.swift b/Tests/Decoder/DictionaryDecoderTests.swift index df95478..2cf18ff 100644 --- a/Tests/Decoder/DictionaryDecoderTests.swift +++ b/Tests/Decoder/DictionaryDecoderTests.swift @@ -133,6 +133,24 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: [String: Float].self, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingNumbersOfOtherTypes() { + struct DecodableStruct: Decodable, Equatable { + let foo: Double + let bar: Float + let baz: Int64 + let qux: Int + } + + let dictionary: [String: Any] = [ + "foo": 123, + "bar": 1.5, + "baz": 456, + "qux": 789.0 + ] + + assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) + } + func testThatDecoderSucceedsWhenDecodingStringToStringDictionary() { let dictionary = [ "foo": "qwe", @@ -682,6 +700,34 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } } + func testThatDecoderFailsWhenDecodingNumberThatDoesNotFit() { + let dictionary = ["foobar": 1.5] + + assertDecoderFails(decoding: [String: Int].self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, _) where type is Int.Type: + return true + + default: + return false + } + } + } + + func testThatDecoderFailsWhenDecodingBoolFromNumber() { + let dictionary = ["foobar": 1] + + assertDecoderFails(decoding: [String: Bool].self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, _) where type is Bool.Type: + return true + + default: + return false + } + } + } + func testThatDecoderFailsWhenDecodingNilForKeyedContainer() { struct DecodableStruct: Decodable { enum CodingKeys: String, CodingKey { From 04e3a83847453b1220d4cc3ef6d323267e9d60cc Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 18:10:44 +0200 Subject: [PATCH 09/20] Compare the types of values instead of casting them --- Sources/Decoder/DictionaryComponentDecoder.swift | 9 +++++---- Sources/Encoder/DictionaryComponentEncoder.swift | 15 ++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index db12f99..df60f4b 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -280,14 +280,15 @@ extension DictionaryComponentDecoder { from component: Any?, at codingPath: [CodingKey] ) throws -> T { - switch T.self { - case is Date.Type: + // The type is compared rather than cast, as a cast costs much more and is made for every value. + switch ObjectIdentifier(T.self) { + case ObjectIdentifier(Date.self): return try decodeDate(from: component, at: codingPath) as! T - case is Data.Type: + case ObjectIdentifier(Data.self): return try decodeData(from: component, at: codingPath) as! T - case is URL.Type: + case ObjectIdentifier(URL.self): return try decodeURL(from: component, at: codingPath) as! T default: diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 1d2c655..1cf243c 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -197,15 +197,16 @@ extension DictionaryComponentEncoder { _ value: T, at codingPath: [CodingKey] ) throws -> DictionaryComponent { - switch value { - case let date as Date: - return try encodeDate(date, at: codingPath) + // The type is compared rather than the value cast, as a cast costs much more and is made for every value. + switch ObjectIdentifier(T.self) { + case ObjectIdentifier(Date.self): + return try encodeDate(value as! Date, at: codingPath) - case let data as Data: - return try encodeData(data, at: codingPath) + case ObjectIdentifier(Data.self): + return try encodeData(value as! Data, at: codingPath) - case let url as URL: - return try encodeURL(url, at: codingPath) + case ObjectIdentifier(URL.self): + return try encodeURL(value as! URL, at: codingPath) default: return try encodeNonPrimitiveValue(value, at: codingPath) From be34d144d78ceb1e2583e15569120b1fe67dedd7 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:26:05 +0200 Subject: [PATCH 10/20] Add decimal strategies that keep Decimal as a number --- DictionaryCoder.xcodeproj/project.pbxproj | 20 +++++ .../Decoder/DictionaryComponentDecoder.swift | 23 ++++++ Sources/Decoder/DictionaryDecoder.swift | 7 ++ .../DictionaryDecimalDecodingStrategy.swift | 21 +++++ .../Options/DictionaryDecodingOptions.swift | 1 + .../Encoder/DictionaryComponentEncoder.swift | 13 +++ Sources/Encoder/DictionaryEncoder.swift | 7 ++ .../DictionaryDecimalEncodingStrategy.swift | 7 ++ .../Options/DictionaryEncodingOptions.swift | 1 + .../DictionaryDecoderStrategiesTests.swift | 81 +++++++++++++++++++ .../DictionaryEncoderStrategiesTests.swift | 28 +++++++ 11 files changed, 209 insertions(+) create mode 100644 Sources/Decoder/Options/DictionaryDecimalDecodingStrategy.swift create mode 100644 Sources/Encoder/Options/DictionaryDecimalEncodingStrategy.swift diff --git a/DictionaryCoder.xcodeproj/project.pbxproj b/DictionaryCoder.xcodeproj/project.pbxproj index f70f984..1e43ba3 100644 --- a/DictionaryCoder.xcodeproj/project.pbxproj +++ b/DictionaryCoder.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ C001ACAA2F027A4B008C37A5 /* MutexStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C001ACA72F027A47008C37A5 /* MutexStorage.swift */; }; C001ACAB2F027A4B008C37A5 /* MutexStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C001ACA72F027A47008C37A5 /* MutexStorage.swift */; }; C006F1AA2406B1F000C3F783 /* DictionaryDataDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */; }; + 34C8CEDCE88D06F5994D6149 /* DictionaryDecimalDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */; }; C006F1AB2406B1F000C3F783 /* DictionaryDateDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF923E1E0AE00F12CCB /* DictionaryDateDecodingStrategy.swift */; }; C006F1AC2406B1F000C3F783 /* DictionaryKeyDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF823E1E0AE00F12CCB /* DictionaryKeyDecodingStrategy.swift */; }; C006F1AD2406B1F000C3F783 /* DictionaryNonConformingFloatDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFB23E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift */; }; @@ -26,6 +27,7 @@ C006F1B22406B1F500C3F783 /* DictionarySingleValueDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFD23E1E0AF00F12CCB /* DictionarySingleValueDecodingContainer.swift */; }; C006F1B32406B1F500C3F783 /* DictionaryUnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFE23E1E0AF00F12CCB /* DictionaryUnkeyedDecodingContainer.swift */; }; C006F1B42406B1FA00C3F783 /* DictionaryDataEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */; }; + 16E10BC5FDEC1D729242BA52 /* DictionaryDecimalEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */; }; C006F1B52406B1FA00C3F783 /* DictionaryDateEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0323E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift */; }; C006F1B62406B1FA00C3F783 /* DictionaryKeyEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0223E1E0AF00F12CCB /* DictionaryKeyEncodingStrategy.swift */; }; C006F1B72406B1FA00C3F783 /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0523E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift */; }; @@ -60,8 +62,11 @@ C013EB1F23E1E0AF00F12CCB /* DictionaryDateDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF923E1E0AE00F12CCB /* DictionaryDateDecodingStrategy.swift */; }; C013EB2023E1E0AF00F12CCB /* DictionaryDateDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF923E1E0AE00F12CCB /* DictionaryDateDecodingStrategy.swift */; }; C013EB2123E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */; }; + 26898CBB383FF7FDFD5F95E8 /* DictionaryDecimalDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */; }; C013EB2223E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */; }; + 51AE1E45DC1E17FA460E14A9 /* DictionaryDecimalDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */; }; C013EB2323E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */; }; + 6766F9B09526EA43F23ABC51 /* DictionaryDecimalDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */; }; C013EB2423E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFB23E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift */; }; C013EB2523E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFB23E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift */; }; C013EB2623E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAFB23E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift */; }; @@ -84,8 +89,11 @@ C013EB3723E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0323E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift */; }; C013EB3823E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0323E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift */; }; C013EB3923E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */; }; + E3C3287C6B11499A5E5B00C1 /* DictionaryDecimalEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */; }; C013EB3A23E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */; }; + 9345D64F759EDFE41E32C67E /* DictionaryDecimalEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */; }; C013EB3B23E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */; }; + 80CA8A45A144A2E368CEF354 /* DictionaryDecimalEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */; }; C013EB3C23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0523E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift */; }; C013EB3D23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0523E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift */; }; C013EB3E23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0523E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift */; }; @@ -182,6 +190,7 @@ C013EAF823E1E0AE00F12CCB /* DictionaryKeyDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryKeyDecodingStrategy.swift; sourceTree = ""; }; C013EAF923E1E0AE00F12CCB /* DictionaryDateDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDateDecodingStrategy.swift; sourceTree = ""; }; C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDataDecodingStrategy.swift; sourceTree = ""; }; + FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDecimalDecodingStrategy.swift; sourceTree = ""; }; C013EAFB23E1E0AF00F12CCB /* DictionaryNonConformingFloatDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryNonConformingFloatDecodingStrategy.swift; sourceTree = ""; }; C013EAFC23E1E0AF00F12CCB /* DictionaryComponentDecoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryComponentDecoder.swift; sourceTree = ""; }; C013EAFD23E1E0AF00F12CCB /* DictionarySingleValueDecodingContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionarySingleValueDecodingContainer.swift; sourceTree = ""; }; @@ -190,6 +199,7 @@ C013EB0223E1E0AF00F12CCB /* DictionaryKeyEncodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryKeyEncodingStrategy.swift; sourceTree = ""; }; C013EB0323E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDateEncodingStrategy.swift; sourceTree = ""; }; C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDataEncodingStrategy.swift; sourceTree = ""; }; + EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDecimalEncodingStrategy.swift; sourceTree = ""; }; C013EB0523E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryNonConformingFloatEncodingStrategy.swift; sourceTree = ""; }; C013EB0623E1E0AF00F12CCB /* DictionaryAnyKeyedEncodingContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryAnyKeyedEncodingContainer.swift; sourceTree = ""; }; C013EB0723E1E0AF00F12CCB /* DictionaryUnkeyedEncodingContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryUnkeyedEncodingContainer.swift; sourceTree = ""; }; @@ -328,6 +338,7 @@ isa = PBXGroup; children = ( C013EAFA23E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift */, + FFD64BF90D40C01B95CBB75B /* DictionaryDecimalDecodingStrategy.swift */, C013EAF923E1E0AE00F12CCB /* DictionaryDateDecodingStrategy.swift */, C013EAFF23E1E0AF00F12CCB /* DictionaryDecodingOptions.swift */, C013EAF823E1E0AE00F12CCB /* DictionaryKeyDecodingStrategy.swift */, @@ -356,6 +367,7 @@ isa = PBXGroup; children = ( C013EB0423E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift */, + EFB19ABEC86D0C1E9BEF2848 /* DictionaryDecimalEncodingStrategy.swift */, C013EB0323E1E0AF00F12CCB /* DictionaryDateEncodingStrategy.swift */, C013EB0B23E1E0AF00F12CCB /* DictionaryEncodingOptions.swift */, C013EB0223E1E0AF00F12CCB /* DictionaryKeyEncodingStrategy.swift */, @@ -785,10 +797,12 @@ C006F1B32406B1F500C3F783 /* DictionaryUnkeyedDecodingContainer.swift in Sources */, C006F1BC2406B1FE00C3F783 /* DictionaryEncoder.swift in Sources */, C006F1B42406B1FA00C3F783 /* DictionaryDataEncodingStrategy.swift in Sources */, + 16E10BC5FDEC1D729242BA52 /* DictionaryDecimalEncodingStrategy.swift in Sources */, C006F1B02406B1F500C3F783 /* DictionaryDecodingOptions.swift in Sources */, C006F1B22406B1F500C3F783 /* DictionarySingleValueDecodingContainer.swift in Sources */, C006F1AE2406B1F500C3F783 /* DictionaryComponentDecoder.swift in Sources */, C006F1AA2406B1F000C3F783 /* DictionaryDataDecodingStrategy.swift in Sources */, + 34C8CEDCE88D06F5994D6149 /* DictionaryDecimalDecodingStrategy.swift in Sources */, C006F1AD2406B1F000C3F783 /* DictionaryNonConformingFloatDecodingStrategy.swift in Sources */, C006F1B82406B1FE00C3F783 /* DictionaryAnyKeyedEncodingContainer.swift in Sources */, C006F1AF2406B1F500C3F783 /* DictionaryDecoder.swift in Sources */, @@ -820,9 +834,11 @@ C013EB2723E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1823E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, C013EB2123E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */, + 26898CBB383FF7FDFD5F95E8 /* DictionaryDecimalDecodingStrategy.swift in Sources */, C013EB4523E1E0AF00F12CCB /* DictionaryEncoder.swift in Sources */, C013EB1523E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */, C013EB3923E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */, + E3C3287C6B11499A5E5B00C1 /* DictionaryDecimalEncodingStrategy.swift in Sources */, C013EB3023E1E0AF00F12CCB /* DictionaryDecodingOptions.swift in Sources */, C013EB2D23E1E0AF00F12CCB /* DictionaryUnkeyedDecodingContainer.swift in Sources */, C013EB5123E1E0AF00F12CCB /* DictionaryKeyedEncodingContainer.swift in Sources */, @@ -869,9 +885,11 @@ C013EB2823E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1923E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, C013EB2223E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */, + 51AE1E45DC1E17FA460E14A9 /* DictionaryDecimalDecodingStrategy.swift in Sources */, C013EB4623E1E0AF00F12CCB /* DictionaryEncoder.swift in Sources */, C013EB1623E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */, C013EB3A23E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */, + 9345D64F759EDFE41E32C67E /* DictionaryDecimalEncodingStrategy.swift in Sources */, C013EB3123E1E0AF00F12CCB /* DictionaryDecodingOptions.swift in Sources */, C013EB2E23E1E0AF00F12CCB /* DictionaryUnkeyedDecodingContainer.swift in Sources */, C013EB5223E1E0AF00F12CCB /* DictionaryKeyedEncodingContainer.swift in Sources */, @@ -918,9 +936,11 @@ C013EB2923E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1A23E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, C013EB2323E1E0AF00F12CCB /* DictionaryDataDecodingStrategy.swift in Sources */, + 6766F9B09526EA43F23ABC51 /* DictionaryDecimalDecodingStrategy.swift in Sources */, C013EB4723E1E0AF00F12CCB /* DictionaryEncoder.swift in Sources */, C013EB1723E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */, C013EB3B23E1E0AF00F12CCB /* DictionaryDataEncodingStrategy.swift in Sources */, + 80CA8A45A144A2E368CEF354 /* DictionaryDecimalEncodingStrategy.swift in Sources */, C013EB3223E1E0AF00F12CCB /* DictionaryDecodingOptions.swift in Sources */, C013EB2F23E1E0AF00F12CCB /* DictionaryUnkeyedDecodingContainer.swift in Sources */, C013EB5323E1E0AF00F12CCB /* DictionaryKeyedEncodingContainer.swift in Sources */, diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index df60f4b..cf7550c 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -44,6 +44,26 @@ extension DictionaryComponentDecoder { CFGetTypeID(number) == CFBooleanGetTypeID() } + private func decodeDecimal(from component: Any?, at codingPath: [CodingKey]) throws -> Decimal { + let strategy = options.decimalDecodingStrategy + + if strategy.contains(.number) { + if let decimal = component as? Decimal { + return decimal + } + + if let number = component as? NSNumber, !isBoolean(number) { + return number.decimalValue + } + } + + guard strategy.contains(.deferredToDecimal) else { + throw DecodingError.invalidComponent(component, of: Decimal.self, at: codingPath) + } + + return try decodeNonPrimitiveValue(from: component, at: codingPath) + } + private func decodeNonPrimitiveValue( of type: T.Type = T.self, from component: Any?, @@ -291,6 +311,9 @@ extension DictionaryComponentDecoder { case ObjectIdentifier(URL.self): return try decodeURL(from: component, at: codingPath) as! T + case ObjectIdentifier(Decimal.self): + return try decodeDecimal(from: component, at: codingPath) as! T + default: return try decodeNonPrimitiveValue(from: component, at: codingPath) } diff --git a/Sources/Decoder/DictionaryDecoder.swift b/Sources/Decoder/DictionaryDecoder.swift index f96ee3d..6967c2c 100644 --- a/Sources/Decoder/DictionaryDecoder.swift +++ b/Sources/Decoder/DictionaryDecoder.swift @@ -17,6 +17,11 @@ public final class DictionaryDecoder: Sendable { set { optionsMutex.withLock { $0.dataDecodingStrategy = newValue } } } + public var decimalDecodingStrategy: DictionaryDecimalDecodingStrategy { + get { optionsMutex.withLock { $0.decimalDecodingStrategy } } + set { optionsMutex.withLock { $0.decimalDecodingStrategy = newValue } } + } + public var nonConformingFloatDecodingStrategy: DictionaryNonConformingFloatDecodingStrategy { get { optionsMutex.withLock { $0.nonConformingFloatDecodingStrategy } } set { optionsMutex.withLock { $0.nonConformingFloatDecodingStrategy = newValue } } @@ -37,6 +42,7 @@ public final class DictionaryDecoder: Sendable { public init( dateDecodingStrategy: DictionaryDateDecodingStrategy = .deferredToDate, dataDecodingStrategy: DictionaryDataDecodingStrategy = .base64, + decimalDecodingStrategy: DictionaryDecimalDecodingStrategy = .deferredToDecimal, nonConformingFloatDecodingStrategy: DictionaryNonConformingFloatDecodingStrategy = .throw, keyDecodingStrategy: DictionaryKeyDecodingStrategy = .useDefaultKeys, userInfo: [CodingUserInfoKey: Sendable] = [:] @@ -44,6 +50,7 @@ public final class DictionaryDecoder: Sendable { let options = DictionaryDecodingOptions( dateDecodingStrategy: dateDecodingStrategy, dataDecodingStrategy: dataDecodingStrategy, + decimalDecodingStrategy: decimalDecodingStrategy, nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy, keyDecodingStrategy: keyDecodingStrategy ) diff --git a/Sources/Decoder/Options/DictionaryDecimalDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryDecimalDecodingStrategy.swift new file mode 100644 index 0000000..c41bb70 --- /dev/null +++ b/Sources/Decoder/Options/DictionaryDecimalDecodingStrategy.swift @@ -0,0 +1,21 @@ +/// The strategies for decoding decimals, which can be combined to accept both representations. +public struct DictionaryDecimalDecodingStrategy: OptionSet, Sendable { + + // MARK: - Type Properties + + /// The strategy that decodes decimals from the keyed representation of the decimal type itself. + public static let deferredToDecimal = Self(rawValue: 1 << 0) + + /// The strategy that decodes decimals from numbers, such as `Decimal`, `NSDecimalNumber` or `Double`. + public static let number = Self(rawValue: 1 << 1) + + // MARK: - Instance Properties + + public let rawValue: Int + + // MARK: - Initializers + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Sources/Decoder/Options/DictionaryDecodingOptions.swift b/Sources/Decoder/Options/DictionaryDecodingOptions.swift index 5c8a3d1..747fa35 100644 --- a/Sources/Decoder/Options/DictionaryDecodingOptions.swift +++ b/Sources/Decoder/Options/DictionaryDecodingOptions.swift @@ -4,6 +4,7 @@ internal struct DictionaryDecodingOptions { internal var dateDecodingStrategy: DictionaryDateDecodingStrategy internal var dataDecodingStrategy: DictionaryDataDecodingStrategy + internal var decimalDecodingStrategy: DictionaryDecimalDecodingStrategy internal var nonConformingFloatDecodingStrategy: DictionaryNonConformingFloatDecodingStrategy internal var keyDecodingStrategy: DictionaryKeyDecodingStrategy } diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 1cf243c..88277d7 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -104,6 +104,16 @@ extension DictionaryComponentEncoder { } } + private func encodeDecimal(_ decimal: Decimal, at codingPath: [CodingKey]) throws -> DictionaryComponent { + switch options.decimalEncodingStrategy { + case .deferredToDecimal: + return try encodeNonPrimitiveValue(decimal, at: codingPath) + + case .number: + return encodePrimitiveValue(decimal, at: codingPath) + } + } + private func encodeFloatingPoint( _ value: T, at codingPath: [CodingKey] @@ -208,6 +218,9 @@ extension DictionaryComponentEncoder { case ObjectIdentifier(URL.self): return try encodeURL(value as! URL, at: codingPath) + case ObjectIdentifier(Decimal.self): + return try encodeDecimal(value as! Decimal, at: codingPath) + default: return try encodeNonPrimitiveValue(value, at: codingPath) } diff --git a/Sources/Encoder/DictionaryEncoder.swift b/Sources/Encoder/DictionaryEncoder.swift index 3ece945..6ad9668 100644 --- a/Sources/Encoder/DictionaryEncoder.swift +++ b/Sources/Encoder/DictionaryEncoder.swift @@ -17,6 +17,11 @@ public final class DictionaryEncoder: Sendable { set { optionsMutex.withLock { $0.dataEncodingStrategy = newValue } } } + public var decimalEncodingStrategy: DictionaryDecimalEncodingStrategy { + get { optionsMutex.withLock { $0.decimalEncodingStrategy } } + set { optionsMutex.withLock { $0.decimalEncodingStrategy = newValue } } + } + public var nonConformingFloatEncodingStrategy: DictionaryNonConformingFloatEncodingStrategy { get { optionsMutex.withLock { $0.nonConformingFloatEncodingStrategy } } set { optionsMutex.withLock { $0.nonConformingFloatEncodingStrategy = newValue } } @@ -42,6 +47,7 @@ public final class DictionaryEncoder: Sendable { public init( dateEncodingStrategy: DictionaryDateEncodingStrategy = .deferredToDate, dataEncodingStrategy: DictionaryDataEncodingStrategy = .base64, + decimalEncodingStrategy: DictionaryDecimalEncodingStrategy = .deferredToDecimal, nonConformingFloatEncodingStrategy: DictionaryNonConformingFloatEncodingStrategy = .throw, nilEncodingStrategy: DictionaryNilEncodingStrategy = .useNil, keyEncodingStrategy: DictionaryKeyEncodingStrategy = .useDefaultKeys, @@ -50,6 +56,7 @@ public final class DictionaryEncoder: Sendable { let options = DictionaryEncodingOptions( dateEncodingStrategy: dateEncodingStrategy, dataEncodingStrategy: dataEncodingStrategy, + decimalEncodingStrategy: decimalEncodingStrategy, nonConformingFloatEncodingStrategy: nonConformingFloatEncodingStrategy, nilEncodingStrategy: nilEncodingStrategy, keyEncodingStrategy: keyEncodingStrategy diff --git a/Sources/Encoder/Options/DictionaryDecimalEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryDecimalEncodingStrategy.swift new file mode 100644 index 0000000..8c6883c --- /dev/null +++ b/Sources/Encoder/Options/DictionaryDecimalEncodingStrategy.swift @@ -0,0 +1,7 @@ +public enum DictionaryDecimalEncodingStrategy: Sendable { + + // MARK: - Enumeration Cases + + case deferredToDecimal + case number +} diff --git a/Sources/Encoder/Options/DictionaryEncodingOptions.swift b/Sources/Encoder/Options/DictionaryEncodingOptions.swift index c4dd528..3351d84 100644 --- a/Sources/Encoder/Options/DictionaryEncodingOptions.swift +++ b/Sources/Encoder/Options/DictionaryEncodingOptions.swift @@ -4,6 +4,7 @@ internal struct DictionaryEncodingOptions { internal var dateEncodingStrategy: DictionaryDateEncodingStrategy internal var dataEncodingStrategy: DictionaryDataEncodingStrategy + internal var decimalEncodingStrategy: DictionaryDecimalEncodingStrategy internal var nonConformingFloatEncodingStrategy: DictionaryNonConformingFloatEncodingStrategy internal var nilEncodingStrategy: DictionaryNilEncodingStrategy internal var keyEncodingStrategy: DictionaryKeyEncodingStrategy diff --git a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift index df9c07c..3f8a6d3 100644 --- a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift +++ b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift @@ -299,6 +299,87 @@ final class DictionaryDecoderStrategiesTests: XCTestCase, DictionaryDecoderTesti // MARK: - + func testThatDecoderSucceedsWhenDecodingDecimal() throws { + let value = [ + "foo": Decimal(string: "1.5")!, + "bar": Decimal(string: "-2.25")! + ] + + let dictionary = try DictionaryEncoder().encode(value) + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + + func testThatDecoderFailsWhenDecodingInvalidDecimal() { + let dictionary = ["foobar": 1.5] + + assertDecoderFails(decoding: [String: Decimal].self, from: dictionary) { error in + switch error { + case DecodingError.typeMismatch: + return true + + default: + return false + } + } + } + + func testThatDecoderSucceedsWhenDecodingDecimalFromNumber() { + decoder.decimalDecodingStrategy = .number + + let dictionary: [String: Any] = [ + "foo": Decimal(string: "1.5")!, + "bar": NSDecimalNumber(string: "-2.25"), + "baz": 0.1, + "qux": 3 + ] + + let value = [ + "foo": Decimal(string: "1.5")!, + "bar": Decimal(string: "-2.25")!, + "baz": Decimal(string: "0.1")!, + "qux": Decimal(3) + ] + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + + func testThatDecoderFailsWhenDecodingInvalidDecimalFromNumber() throws { + decoder.decimalDecodingStrategy = .number + + let dictionaries: [[String: Any]] = [ + ["foobar": true], + try DictionaryEncoder().encode(["foobar": Decimal(string: "1.5")!]) + ] + + for dictionary in dictionaries { + assertDecoderFails(decoding: [String: Decimal].self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, _) where type is Decimal.Type: + return true + + default: + return false + } + } + } + } + + func testThatDecoderSucceedsWhenDecodingDecimalFromNumberOrKeyedRepresentation() throws { + decoder.decimalDecodingStrategy = [.deferredToDecimal, .number] + + var dictionary = try DictionaryEncoder().encode(["foo": Decimal(string: "1.5")!]) + + dictionary["bar"] = 0.25 + + let value = [ + "foo": Decimal(string: "1.5")!, + "bar": Decimal(string: "0.25")! + ] + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + func testThatDecoderFailsWhenDecodingNonConformingFloat() { decoder.nonConformingFloatDecodingStrategy = .throw diff --git a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift index 5148404..1d92d20 100644 --- a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift +++ b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift @@ -137,6 +137,34 @@ final class DictionaryEncoderStrategiesTests: XCTestCase, DictionaryEncoderTesti // MARK: - + func testThatEncoderSucceedsWhenEncodingDecimal() throws { + struct DeferredDecimal: Encodable { + let value: Decimal + + func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + } + + let decimal = Decimal(string: "1.5")! + let expectedDictionary = try encoder.encode(["foobar": DeferredDecimal(value: decimal)]) + + XCTAssert(expectedDictionary["foobar"] is [String: Any]) + + assertEncoderSucceeds(encoding: ["foobar": decimal], expecting: expectedDictionary) + } + + func testThatEncoderSucceedsWhenEncodingDecimalToNumber() { + encoder.decimalEncodingStrategy = .number + + let value = [ + "foo": Decimal(string: "1.5")!, + "bar": Decimal(string: "-2.25")! + ] + + assertEncoderSucceeds(encoding: value, expecting: value) + } + func testThatEncoderFailsWhenEncodingPositiveInfinityFloat() { encoder.nonConformingFloatEncodingStrategy = .throw From edd17ba07908a387ed17cf2833daa04a95d0efe3 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:27:09 +0200 Subject: [PATCH 11/20] Support Int128 and UInt128 --- .../Decoder/DictionaryComponentDecoder.swift | 47 +++++++++++++++++++ .../DictionaryKeyedDecodingContainer.swift | 14 ++++++ ...ctionarySingleValueDecodingContainer.swift | 14 ++++++ .../DictionaryUnkeyedDecodingContainer.swift | 14 ++++++ .../Encoder/DictionaryComponentEncoder.swift | 14 ++++++ .../DictionaryKeyedEncodingContainer.swift | 14 ++++++ ...ctionarySingleValueEncodingContainer.swift | 14 ++++++ .../DictionaryUnkeyedEncodingContainer.swift | 14 ++++++ Tests/Decoder/DictionaryDecoderTests.swift | 46 ++++++++++++++++++ Tests/Encoder/DictionaryEncoderTests.swift | 20 ++++++++ 10 files changed, 211 insertions(+) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index cf7550c..abd0795 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -64,6 +64,39 @@ extension DictionaryComponentDecoder { return try decodeNonPrimitiveValue(from: component, at: codingPath) } +#if compiler(>=6.0) + // `NSNumber` does not bridge 128-bit integers, so other integers are converted exactly. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + private func decodeWideInteger( + of type: T.Type = T.self, + from component: Any?, + at codingPath: [CodingKey] + ) throws -> T { + if let value = component as? T { + return value + } + + let value: T? + + switch component { + case let integer as any BinaryInteger: + value = T(exactly: integer) + + case let number as NSNumber where !isBoolean(number): + value = (number as? Int64).flatMap(T.init(exactly:)) ?? (number as? UInt64).flatMap(T.init(exactly:)) + + default: + value = nil + } + + guard let value else { + throw DecodingError.invalidComponent(component, of: T.self, at: codingPath) + } + + return value + } +#endif + private func decodeNonPrimitiveValue( of type: T.Type = T.self, from component: Any?, @@ -263,6 +296,13 @@ extension DictionaryComponentDecoder { try decodePrimitiveValue(from: component, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int128 { + try decodeWideInteger(from: component, at: codingPath) + } +#endif + internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt { try decodePrimitiveValue(from: component, at: codingPath) } @@ -283,6 +323,13 @@ extension DictionaryComponentDecoder { try decodePrimitiveValue(from: component, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt128 { + try decodeWideInteger(from: component, at: codingPath) + } +#endif + internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Double { try decodeFloatingPointValue(from: component, at: codingPath) } diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index 886b4de..f843ed0 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -98,6 +98,13 @@ internal class DictionaryKeyedDecodingContainer: try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: Int128.Type, forKey key: Key) throws -> Int128 { + try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) + } +#endif + internal func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) } @@ -118,6 +125,13 @@ internal class DictionaryKeyedDecodingContainer: try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: UInt128.Type, forKey key: Key) throws -> UInt128 { + try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) + } +#endif + internal func decode(_ type: Double.Type, forKey key: Key) throws -> Double { try decodeComponentValue(from: try component(forKey: key), at: codingPath.appending(key)) } diff --git a/Sources/Decoder/DictionarySingleValueDecodingContainer.swift b/Sources/Decoder/DictionarySingleValueDecodingContainer.swift index 238fe51..b1b825f 100644 --- a/Sources/Decoder/DictionarySingleValueDecodingContainer.swift +++ b/Sources/Decoder/DictionarySingleValueDecodingContainer.swift @@ -54,6 +54,13 @@ internal final class DictionarySingleValueDecodingContainer: try decodeComponentValue(from: component, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: Int128.Type) throws -> Int128 { + try decodeComponentValue(from: component, at: codingPath) + } +#endif + internal func decode(_ type: UInt.Type) throws -> UInt { try decodeComponentValue(from: component, at: codingPath) } @@ -74,6 +81,13 @@ internal final class DictionarySingleValueDecodingContainer: try decodeComponentValue(from: component, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: UInt128.Type) throws -> UInt128 { + try decodeComponentValue(from: component, at: codingPath) + } +#endif + internal func decode(_ type: Double.Type) throws -> Double { try decodeComponentValue(from: component, at: codingPath) } diff --git a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift index fd70d03..929c88d 100644 --- a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift @@ -109,6 +109,13 @@ internal final class DictionaryUnkeyedDecodingContainer: try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: Int128.Type) throws -> Int128 { + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } + } +#endif + internal func decode(_ type: UInt.Type) throws -> UInt { try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } @@ -129,6 +136,13 @@ internal final class DictionaryUnkeyedDecodingContainer: try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decode(_ type: UInt128.Type) throws -> UInt128 { + try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } + } +#endif + internal func decode(_ type: Double.Type) throws -> Double { try decodeNextComponent { try decodeComponentValue(from: $0, at: $1) } } diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 88277d7..33267f1 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -171,6 +171,13 @@ extension DictionaryComponentEncoder { encodePrimitiveValue(value, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encodeComponentValue(_ value: Int128, at codingPath: [CodingKey]) -> DictionaryComponent { + encodePrimitiveValue(value, at: codingPath) + } +#endif + internal func encodeComponentValue(_ value: UInt, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } @@ -191,6 +198,13 @@ extension DictionaryComponentEncoder { encodePrimitiveValue(value, at: codingPath) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encodeComponentValue(_ value: UInt128, at codingPath: [CodingKey]) -> DictionaryComponent { + encodePrimitiveValue(value, at: codingPath) + } +#endif + internal func encodeComponentValue(_ value: Double, at codingPath: [CodingKey]) throws -> DictionaryComponent { try encodeFloatingPoint(value, at: codingPath) } diff --git a/Sources/Encoder/DictionaryKeyedEncodingContainer.swift b/Sources/Encoder/DictionaryKeyedEncodingContainer.swift index 874e3a1..7e0dbe1 100644 --- a/Sources/Encoder/DictionaryKeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryKeyedEncodingContainer.swift @@ -54,6 +54,13 @@ internal final class DictionaryKeyedEncodingContainer: container.collectComponent(encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: Int128, forKey key: Key) throws { + container.collectComponent(encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) + } +#endif + internal func encode(_ value: UInt, forKey key: Key) throws { container.collectComponent(encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) } @@ -74,6 +81,13 @@ internal final class DictionaryKeyedEncodingContainer: container.collectComponent(encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: UInt128, forKey key: Key) throws { + container.collectComponent(encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) + } +#endif + internal func encode(_ value: Double, forKey key: Key) throws { container.collectComponent(try encodeComponentValue(value, at: codingPath.appending(key)), forKey: key) } diff --git a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift index 99237bc..24da78b 100644 --- a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift +++ b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift @@ -69,6 +69,13 @@ internal final class DictionarySingleValueEncodingContainer: try collectComponent(encodeComponentValue(value, at: codingPath), for: value) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: Int128) throws { + try collectComponent(encodeComponentValue(value, at: codingPath), for: value) + } +#endif + internal func encode(_ value: UInt) throws { try collectComponent(encodeComponentValue(value, at: codingPath), for: value) } @@ -89,6 +96,13 @@ internal final class DictionarySingleValueEncodingContainer: try collectComponent(encodeComponentValue(value, at: codingPath), for: value) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: UInt128) throws { + try collectComponent(encodeComponentValue(value, at: codingPath), for: value) + } +#endif + internal func encode(_ value: Double) throws { try collectComponent(try encodeComponentValue(value, at: codingPath), for: value) } diff --git a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift index 34fac91..494761e 100644 --- a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift @@ -67,6 +67,13 @@ internal final class DictionaryUnkeyedEncodingContainer: collectComponent(encodeComponentValue(value, at: currentCodingPath)) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: Int128) throws { + collectComponent(encodeComponentValue(value, at: currentCodingPath)) + } +#endif + internal func encode(_ value: UInt) throws { collectComponent(encodeComponentValue(value, at: currentCodingPath)) } @@ -87,6 +94,13 @@ internal final class DictionaryUnkeyedEncodingContainer: collectComponent(encodeComponentValue(value, at: currentCodingPath)) } +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func encode(_ value: UInt128) throws { + collectComponent(encodeComponentValue(value, at: currentCodingPath)) + } +#endif + internal func encode(_ value: Double) throws { collectComponent(try encodeComponentValue(value, at: currentCodingPath)) } diff --git a/Tests/Decoder/DictionaryDecoderTests.swift b/Tests/Decoder/DictionaryDecoderTests.swift index 2cf18ff..253926b 100644 --- a/Tests/Decoder/DictionaryDecoderTests.swift +++ b/Tests/Decoder/DictionaryDecoderTests.swift @@ -151,6 +151,32 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) } +#if compiler(>=6.0) + func testThatDecoderSucceedsWhenDecodingWideIntegers() throws { + guard #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) else { + throw XCTSkip("Int128 and UInt128 are unavailable") + } + + struct DecodableStruct: Decodable, Equatable { + let foo: Int128 + let bar: UInt128 + let baz: Int128 + let qux: [UInt128] + } + + let dictionary: [String: Any] = [ + "foo": Int128.max, + "bar": UInt128.max, + "baz": -123, + "qux": [UInt64.max, NSNumber(value: 456)] as [Any] + ] + + let value = DecodableStruct(foo: .max, bar: .max, baz: -123, qux: [UInt128(UInt64.max), 456]) + + assertDecoderSucceeds(decoding: value, from: dictionary) + } +#endif + func testThatDecoderSucceedsWhenDecodingStringToStringDictionary() { let dictionary = [ "foo": "qwe", @@ -714,6 +740,26 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } } +#if compiler(>=6.0) + func testThatDecoderFailsWhenDecodingWideIntegerThatDoesNotFit() throws { + guard #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) else { + throw XCTSkip("Int128 and UInt128 are unavailable") + } + + let dictionary = ["foobar": 1.5] + + assertDecoderFails(decoding: [String: Int128].self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, _) where type is Int128.Type: + return true + + default: + return false + } + } + } +#endif + func testThatDecoderFailsWhenDecodingBoolFromNumber() { let dictionary = ["foobar": 1] diff --git a/Tests/Encoder/DictionaryEncoderTests.swift b/Tests/Encoder/DictionaryEncoderTests.swift index 17f8083..6b6da2b 100644 --- a/Tests/Encoder/DictionaryEncoderTests.swift +++ b/Tests/Encoder/DictionaryEncoderTests.swift @@ -142,6 +142,26 @@ final class DictionaryEncoderTests: XCTestCase, DictionaryEncoderTesting { assertEncoderSucceeds(encoding: value) } +#if compiler(>=6.0) + func testThatEncoderSucceedsWhenEncodingWideIntegers() throws { + guard #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) else { + throw XCTSkip("Int128 and UInt128 are unavailable") + } + + struct EncodableStruct: Encodable { + let foo = Int128.max + let bar = UInt128.max + let baz: [Int128] = [-1, 2] + } + + let dictionary = try encoder.encode(EncodableStruct()) + + XCTAssertEqual(dictionary["foo"] as? Int128, .max) + XCTAssertEqual(dictionary["bar"] as? UInt128, .max) + XCTAssertEqual(dictionary["baz"] as? [Int128], [-1, 2]) + } +#endif + func testThatEncoderSucceedsWhenEncodingStringToURLDictionary() { let value = [ "foo": URL(string: "https://swift.org")!, From 653b284795e4f279ccc79492199f8f5027684aac Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:33:42 +0200 Subject: [PATCH 12/20] Add ISO 8601 styles and format dates with a shared format style --- DictionaryCoder.xcodeproj/project.pbxproj | 10 ++ .../Decoder/DictionaryComponentDecoder.swift | 4 +- .../DictionaryDateDecodingStrategy.swift | 11 +- .../Encoder/DictionaryComponentEncoder.swift | 17 +-- .../DictionaryDateEncodingStrategy.swift | 8 +- Sources/Tools/DictionaryISO8601Style.swift | 105 ++++++++++++++++++ .../DictionaryDecoderStrategiesTests.swift | 64 +++++++++++ Tests/Decoder/DictionaryDecoderTesting.swift | 17 ++- .../DictionaryEncoderStrategiesTests.swift | 17 +++ Tests/Encoder/DictionaryEncoderTesting.swift | 21 +++- 10 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 Sources/Tools/DictionaryISO8601Style.swift diff --git a/DictionaryCoder.xcodeproj/project.pbxproj b/DictionaryCoder.xcodeproj/project.pbxproj index 1e43ba3..bab8936 100644 --- a/DictionaryCoder.xcodeproj/project.pbxproj +++ b/DictionaryCoder.xcodeproj/project.pbxproj @@ -41,14 +41,18 @@ C006F1BF2406B1FE00C3F783 /* DictionarySingleValueEncodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0923E1E0AF00F12CCB /* DictionarySingleValueEncodingContainer.swift */; }; C006F1C02406B1FE00C3F783 /* DictionaryUnkeyedEncodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0723E1E0AF00F12CCB /* DictionaryUnkeyedEncodingContainer.swift */; }; C006F1C12406B20400C3F783 /* AnyCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */; }; + 40B78C975BEC5E40F990D9AF /* DictionaryISO8601Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */; }; C006F1C22406B20400C3F783 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */; }; C006F1C32406B20400C3F783 /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB0F23E1E0AF00F12CCB /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB1023E1E0AF00F12CCB /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB1123E1E0AF00F12CCB /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB1223E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */; }; + D0C83D1B21B28BEB577B4B03 /* DictionaryISO8601Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */; }; C013EB1323E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */; }; + 1FEE72E38D243B1482E72824 /* DictionaryISO8601Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */; }; C013EB1423E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */; }; + 6D33E77C3C18283A7BADD948 /* DictionaryISO8601Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */; }; C013EB1523E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF523E1E0AE00F12CCB /* DictionaryDecoder.swift */; }; C013EB1623E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF523E1E0AE00F12CCB /* DictionaryDecoder.swift */; }; C013EB1723E1E0AF00F12CCB /* DictionaryDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF523E1E0AE00F12CCB /* DictionaryDecoder.swift */; }; @@ -185,6 +189,7 @@ C006F1A22406B13E00C3F783 /* DictionaryCoder.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DictionaryCoder.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RangeReplaceableCollection+Extensions.swift"; sourceTree = ""; }; C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyCodingKey.swift; sourceTree = ""; }; + 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryISO8601Style.swift; sourceTree = ""; }; C013EAF523E1E0AE00F12CCB /* DictionaryDecoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDecoder.swift; sourceTree = ""; }; C013EAF623E1E0AE00F12CCB /* DictionaryKeyedDecodingContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryKeyedDecodingContainer.swift; sourceTree = ""; }; C013EAF823E1E0AE00F12CCB /* DictionaryKeyDecodingStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryKeyDecodingStrategy.swift; sourceTree = ""; }; @@ -315,6 +320,7 @@ children = ( C001ACA62F027A3B008C37A5 /* Mutex */, C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */, + 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */, C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */, C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */, ); @@ -789,6 +795,7 @@ C006F1C22406B20400C3F783 /* Optional+Extensions.swift in Sources */, C006F1AC2406B1F000C3F783 /* DictionaryKeyDecodingStrategy.swift in Sources */, C006F1C12406B20400C3F783 /* AnyCodingKey.swift in Sources */, + 40B78C975BEC5E40F990D9AF /* DictionaryISO8601Style.swift in Sources */, C006F1C32406B20400C3F783 /* RangeReplaceableCollection+Extensions.swift in Sources */, C006F1B52406B1FA00C3F783 /* DictionaryDateEncodingStrategy.swift in Sources */, C006F1BD2406B1FE00C3F783 /* DictionaryEncodingOptions.swift in Sources */, @@ -830,6 +837,7 @@ C013EB3C23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5723E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1223E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, + D0C83D1B21B28BEB577B4B03 /* DictionaryISO8601Style.swift in Sources */, C001ACAA2F027A4B008C37A5 /* MutexStorage.swift in Sources */, C013EB2723E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1823E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, @@ -881,6 +889,7 @@ C013EB3D23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5823E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1323E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, + 1FEE72E38D243B1482E72824 /* DictionaryISO8601Style.swift in Sources */, C001ACA82F027A4B008C37A5 /* MutexStorage.swift in Sources */, C013EB2823E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1923E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, @@ -932,6 +941,7 @@ C013EB3E23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5923E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1423E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, + 6D33E77C3C18283A7BADD948 /* DictionaryISO8601Style.swift in Sources */, C001ACA92F027A4B008C37A5 /* MutexStorage.swift in Sources */, C013EB2923E1E0AF00F12CCB /* DictionaryComponentDecoder.swift in Sources */, C013EB1A23E1E0AF00F12CCB /* DictionaryKeyedDecodingContainer.swift in Sources */, diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index abd0795..dc74ae0 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -189,10 +189,10 @@ extension DictionaryComponentDecoder { case .millisecondsSince1970: return Date(timeIntervalSince1970: try decodePrimitiveValue(from: component, at: codingPath) / 1000.0) - case .iso8601: + case let .iso8601(style): let formattedDate = try decodePrimitiveValue(of: String.self, from: component, at: codingPath) - guard let date = ISO8601DateFormatter().date(from: formattedDate) else { + guard let date = style.date(from: formattedDate) else { let errorContext = DecodingError.Context( codingPath: codingPath, debugDescription: "Expected date string to be ISO8601-formatted." diff --git a/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift b/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift index c0a6caf..655dc8a 100644 --- a/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift +++ b/Sources/Decoder/Options/DictionaryDateDecodingStrategy.swift @@ -14,12 +14,19 @@ public enum DictionaryDateDecodingStrategy: Sendable { /// The strategy that decodes dates in terms of milliseconds since midnight UTC on January 1st, 1970. case millisecondsSince1970 - /// The strategy that formats dates according to the ISO 8601 standard. - case iso8601 + /// The strategy that formats dates according to the ISO 8601 standard in the given style. + case iso8601(style: DictionaryISO8601Style) /// The strategy that defers formatting settings to a supplied date formatter. case formatted(DateFormatter) /// The strategy that formats custom dates by calling a user-defined function. case custom(@Sendable (_ decoder: Decoder) throws -> Date) + + // MARK: - Type Properties + + /// The strategy that formats dates according to the ISO 8601 standard in the style of `ISO8601DateFormatter`. + public static var iso8601: Self { + .iso8601(style: .dateFormatter) + } } diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 33267f1..5472cbe 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -71,14 +71,8 @@ extension DictionaryComponentEncoder { case .secondsSince1970: return encodePrimitiveValue(date.timeIntervalSince1970, at: codingPath) - case .iso8601: - let formattedDate = ISO8601DateFormatter.string( - from: date, - timeZone: .iso8601TimeZone, - formatOptions: .withInternetDateTime - ) - - return encodePrimitiveValue(formattedDate, at: codingPath) + case let .iso8601(style): + return encodePrimitiveValue(style.string(from: date), at: codingPath) case let .formatted(dateFormatter): return encodePrimitiveValue(dateFormatter.string(from: date), at: codingPath) @@ -241,13 +235,6 @@ extension DictionaryComponentEncoder { } } -extension TimeZone { - - // MARK: - Type Properties - - fileprivate static let iso8601TimeZone = TimeZone(secondsFromGMT: 0)! -} - extension EncodingError { // MARK: - Type Methods diff --git a/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift b/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift index f310280..a092791 100644 --- a/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift +++ b/Sources/Encoder/Options/DictionaryDateEncodingStrategy.swift @@ -9,8 +9,14 @@ public enum DictionaryDateEncodingStrategy: Sendable { case millisecondsSince1970 case secondsSince1970 - case iso8601 + case iso8601(style: DictionaryISO8601Style) case formatted(DateFormatter) case custom(@Sendable (_ date: Date, _ encoder: Encoder) throws -> Void) + + // MARK: - Type Properties + + public static var iso8601: Self { + .iso8601(style: .dateFormatter) + } } diff --git a/Sources/Tools/DictionaryISO8601Style.swift b/Sources/Tools/DictionaryISO8601Style.swift new file mode 100644 index 0000000..dee232c --- /dev/null +++ b/Sources/Tools/DictionaryISO8601Style.swift @@ -0,0 +1,105 @@ +import Foundation + +/// The styles of dates formatted according to the ISO 8601 standard. +public enum DictionaryISO8601Style: Sendable { + + // MARK: - Enumeration Cases + + /// The style of `ISO8601DateFormatter`, the default one. + case dateFormatter + + /// The style of `Date.ISO8601FormatStyle`, as in `JSONEncoder` and `JSONDecoder`: + /// fractions of a second are dropped when encoding and accepted when decoding. + case formatStyle +} + +extension DictionaryISO8601Style { + + // MARK: - Instance Methods + + internal func string(from date: Date) -> String { + switch self { + case .dateFormatter: + // `ISO8601DateFormatter` rounds dates to milliseconds before dropping fractions of a second. + let milliseconds = (date.timeIntervalSince1970 * 1000 + 0.5).rounded(.down) + let seconds = (milliseconds / 1000).rounded(.down) + + return Date.ISO8601FormatStyle.internetDateTime.format(Date(timeIntervalSince1970: seconds)) + + case .formatStyle: + return Date.ISO8601FormatStyle.internetDateTime.format(date) + } + } + + internal func date(from string: String) -> Date? { + switch self { + case .dateFormatter: + // Both parse dates with whole seconds alike, but the format style is much faster. + if string.isInternetDateTimeWithWholeSeconds, + let date = try? Date.ISO8601FormatStyle.internetDateTime.parse(string) { + return date + } + + return ISO8601DateFormatter().date(from: string) + + case .formatStyle: + return try? Date.ISO8601FormatStyle.internetDateTime.parse(string) + } + } +} + +extension Date.ISO8601FormatStyle { + + // MARK: - Type Properties + + // Creating a style costs nearly as much as formatting a date with it, so a single one is shared. + fileprivate static let internetDateTime = Self() +} + +extension String { + + // MARK: - Type Properties + + // The ranges of the year, month, day, hour, minute, second and time zone offset in hours and minutes. + private static let internetDateTimeFieldRanges = [1...9999, 1...12, 1...31, 0...23, 0...59, 0...59, 0...23, 0...59] + + // MARK: - Instance Properties + + // Whether the string is like `2001-02-03T04:05:06Z` or `2001-02-03T04:05:06+07:00` with fields in their ranges, + // unlike strings with fractions of a second or a leap second, for example, + // which only `Date.ISO8601FormatStyle` accepts. + fileprivate var isInternetDateTimeWithWholeSeconds: Bool { + let template = utf8.count == 20 ? "0000-00-00T00:00:00Z" : "0000-00-00T00:00:00+00:00" + + guard utf8.count == template.utf8.count else { + return false + } + + var field = 0 + var value = 0 + + for (character, templateCharacter) in zip(utf8, template.utf8) { + if templateCharacter == UInt8(ascii: "0") { + guard (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(character) else { + return false + } + + value = value * 10 + Int(character - UInt8(ascii: "0")) + continue + } + + let isSeparator = character == templateCharacter + || templateCharacter == UInt8(ascii: "+") && character == UInt8(ascii: "-") + + guard isSeparator, Self.internetDateTimeFieldRanges[field].contains(value) else { + return false + } + + field += 1 + value = 0 + } + + // A time zone offset ends with its minutes rather than a separator. + return field == 6 || Self.internetDateTimeFieldRanges[field].contains(value) + } +} diff --git a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift index 3f8a6d3..a02affb 100644 --- a/Tests/Decoder/DictionaryDecoderStrategiesTests.swift +++ b/Tests/Decoder/DictionaryDecoderStrategiesTests.swift @@ -123,6 +123,70 @@ final class DictionaryDecoderStrategiesTests: XCTestCase, DictionaryDecoderTesti } } + func testThatDecoderSucceedsWhenDecodingDateWithTimeZoneFromISO8601Format() { + decoder.dateDecodingStrategy = .iso8601 + + let dictionary = [ + "foo": "2001-01-01T01:02:03+01:00", + "bar": "2001-01-01T01:02:03 +01:00" + ] + + let value = [ + "foo": Date(timeIntervalSinceReferenceDate: 123), + "bar": Date(timeIntervalSinceReferenceDate: 123) + ] + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + + func testThatDecoderFailsWhenDecodingDateWithFractionalSecondsFromISO8601Format() { + decoder.dateDecodingStrategy = .iso8601 + + let dictionary = ["foobar": "2001-01-01T00:02:03.5Z"] + + assertDecoderFails(decoding: [String: Date].self, from: dictionary) { error in + switch error { + case DecodingError.dataCorrupted: + return true + + default: + return false + } + } + } + + func testThatDecoderSucceedsWhenDecodingDateFromISO8601FormatStyle() { + decoder.dateDecodingStrategy = .iso8601(style: .formatStyle) + + let dictionary = [ + "foo": "2001-01-01T00:02:03Z", + "bar": "2001-01-01T00:02:03.5Z" + ] + + let value = [ + "foo": Date(timeIntervalSinceReferenceDate: 123), + "bar": Date(timeIntervalSinceReferenceDate: 123.5) + ] + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + + func testThatDecoderFailsWhenDecodingInvalidDateFromISO8601FormatStyle() { + decoder.dateDecodingStrategy = .iso8601(style: .formatStyle) + + let dictionary = ["foobar": "2001-01-01T00:02:03 +01:00"] + + assertDecoderFails(decoding: [String: Date].self, from: dictionary) { error in + switch error { + case DecodingError.dataCorrupted: + return true + + default: + return false + } + } + } + func testThatDecoderSucceedsWhenDecodingDateUsingFormatter() { let dateFormatter = DateFormatter() diff --git a/Tests/Decoder/DictionaryDecoderTesting.swift b/Tests/Decoder/DictionaryDecoderTesting.swift index 3a68f74..75723a1 100644 --- a/Tests/Decoder/DictionaryDecoderTesting.swift +++ b/Tests/Decoder/DictionaryDecoderTesting.swift @@ -152,8 +152,21 @@ extension DictionaryDateDecodingStrategy { case .secondsSince1970: return .secondsSince1970 - case .iso8601: - return .iso8601 + case .iso8601(style: .dateFormatter): + return .custom { decoder in + let container = try decoder.singleValueContainer() + + guard let date = ISO8601DateFormatter().date(from: try container.decode(String.self)) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date") + } + + return date + } + + case .iso8601(style: .formatStyle): + return .custom { decoder in + try Date.ISO8601FormatStyle().parse(try decoder.singleValueContainer().decode(String.self)) + } case let .formatted(dateFormatter): return .formatted(dateFormatter) diff --git a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift index 1d92d20..ced3397 100644 --- a/Tests/Encoder/DictionaryEncoderStrategiesTests.swift +++ b/Tests/Encoder/DictionaryEncoderStrategiesTests.swift @@ -68,6 +68,23 @@ final class DictionaryEncoderStrategiesTests: XCTestCase, DictionaryEncoderTesti assertEncoderSucceeds(encoding: value) } + func testThatEncoderSucceedsWhenEncodingDateWithFractionalSecondsToISO8601Format() { + encoder.dateEncodingStrategy = .iso8601 + + let value = ["foobar": Date(timeIntervalSince1970: 0.9999)] + + // Dates are rounded to milliseconds before fractions of a second are dropped, as in `ISO8601DateFormatter`. + assertEncoderSucceeds(encoding: value, expecting: ["foobar": "1970-01-01T00:00:01Z"]) + } + + func testThatEncoderSucceedsWhenEncodingDateToISO8601FormatStyle() { + encoder.dateEncodingStrategy = .iso8601(style: .formatStyle) + + let value = ["foobar": Date(timeIntervalSince1970: 0.9999)] + + assertEncoderSucceeds(encoding: value, expecting: ["foobar": "1970-01-01T00:00:00Z"]) + } + func testThatEncoderSucceedsWhenEncodingDateUsingFormatter() { let dateFormatter = DateFormatter() diff --git a/Tests/Encoder/DictionaryEncoderTesting.swift b/Tests/Encoder/DictionaryEncoderTesting.swift index 28c2345..b61c6cf 100644 --- a/Tests/Encoder/DictionaryEncoderTesting.swift +++ b/Tests/Encoder/DictionaryEncoderTesting.swift @@ -118,8 +118,25 @@ extension DictionaryDateEncodingStrategy { case .secondsSince1970: return .secondsSince1970 - case .iso8601: - return .iso8601 + case .iso8601(style: .dateFormatter): + return .custom { date, encoder in + var container = encoder.singleValueContainer() + + try container.encode( + ISO8601DateFormatter.string( + from: date, + timeZone: TimeZone(secondsFromGMT: 0)!, + formatOptions: .withInternetDateTime + ) + ) + } + + case .iso8601(style: .formatStyle): + return .custom { date, encoder in + var container = encoder.singleValueContainer() + + try container.encode(Date.ISO8601FormatStyle().format(date)) + } case let .formatted(dateFormatter): return .formatted(dateFormatter) From cf01d57ab8031b7773b16396eb8cc9b7e656e237 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 02:39:29 +0200 Subject: [PATCH 13/20] Inline hot paths and consume stored arguments --- .../Decoder/DictionaryComponentDecoder.swift | 34 ++++++++++++++----- .../DictionaryKeyedDecodingContainer.swift | 2 ++ .../DictionaryUnkeyedDecodingContainer.swift | 6 ++-- .../DictionaryAnyKeyedEncodingContainer.swift | 4 ++- .../Encoder/DictionaryComponentEncoder.swift | 30 ++++++++++++---- ...ctionarySingleValueEncodingContainer.swift | 3 +- .../DictionaryUnkeyedEncodingContainer.swift | 4 ++- Sources/Tools/Optional+Extensions.swift | 1 + ...angeReplaceableCollection+Extensions.swift | 2 ++ 9 files changed, 67 insertions(+), 19 deletions(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index dc74ae0..08ca0e1 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -10,6 +10,7 @@ extension DictionaryComponentDecoder { // MARK: - Instance Methods + @inline(__always) private func decodePrimitiveValue( of type: T.Type = T.self, from component: Any?, @@ -99,8 +100,8 @@ extension DictionaryComponentDecoder { private func decodeNonPrimitiveValue( of type: T.Type = T.self, - from component: Any?, - at codingPath: [CodingKey] + from component: consuming Any?, + at codingPath: consuming [CodingKey] ) throws -> T { let decoder = DictionarySingleValueDecodingContainer( component: component, @@ -114,8 +115,8 @@ extension DictionaryComponentDecoder { private func decodeCustomizedValue( of type: T.Type = T.self, - from component: Any?, - at codingPath: [CodingKey], + from component: consuming Any?, + at codingPath: consuming [CodingKey], closure: (_ decoder: Decoder) throws -> T ) throws -> T { let decoder = DictionarySingleValueDecodingContainer( @@ -178,7 +179,7 @@ extension DictionaryComponentDecoder { return number } - private func decodeDate(from component: Any?, at codingPath: [CodingKey]) throws -> Date { + private func decodeDate(from component: consuming Any?, at codingPath: consuming [CodingKey]) throws -> Date { switch options.dateDecodingStrategy { case .deferredToDate: return try decodeNonPrimitiveValue(from: component, at: codingPath) @@ -222,7 +223,7 @@ extension DictionaryComponentDecoder { } } - private func decodeData(from component: Any?, at codingPath: [CodingKey]) throws -> Data { + private func decodeData(from component: consuming Any?, at codingPath: consuming [CodingKey]) throws -> Data { switch options.dataDecodingStrategy { case .deferredToData: return try decodeNonPrimitiveValue(from: component, at: codingPath) @@ -268,84 +269,101 @@ extension DictionaryComponentDecoder { // MARK: - + @inline(__always) internal func decodeNilComponent(from component: Any?) -> Bool { component.isNil || component is NSNull } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Bool { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int8 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int16 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int32 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int64 { try decodePrimitiveValue(from: component, at: codingPath) } #if compiler(>=6.0) @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Int128 { try decodeWideInteger(from: component, at: codingPath) } #endif + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt8 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt16 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt32 { try decodePrimitiveValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt64 { try decodePrimitiveValue(from: component, at: codingPath) } #if compiler(>=6.0) @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> UInt128 { try decodeWideInteger(from: component, at: codingPath) } #endif + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Double { try decodeFloatingPointValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> Float { try decodeFloatingPointValue(from: component, at: codingPath) } + @inline(__always) internal func decodeComponentValue(from component: Any?, at codingPath: [CodingKey]) throws -> String { try decodePrimitiveValue(from: component, at: codingPath) } internal func decodeComponentValue( of type: T.Type, - from component: Any?, - at codingPath: [CodingKey] + from component: consuming Any?, + at codingPath: consuming [CodingKey] ) throws -> T { // The type is compared rather than cast, as a cast costs much more and is made for every value. switch ObjectIdentifier(T.self) { diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index f843ed0..80813ce 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -40,6 +40,7 @@ internal class DictionaryKeyedDecodingContainer: // MARK: - Instance Methods + @inline(__always) private func component(of type: T.Type = T.self, forKey key: Key) throws -> T { let anyComponent = components[key.stringValue] @@ -55,6 +56,7 @@ internal class DictionaryKeyedDecodingContainer: return component } + @inline(__always) private func superDecoder(forAnyKey key: CodingKey) throws -> Decoder { DictionarySingleValueDecodingContainer( component: components[key.stringValue], diff --git a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift index 929c88d..4ff1731 100644 --- a/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryUnkeyedDecodingContainer.swift @@ -11,6 +11,7 @@ internal final class DictionaryUnkeyedDecodingContainer: internal private(set) var currentIndex = 0 + @inline(__always) internal var currentCodingPath: [CodingKey] { codingPath.appending(AnyCodingKey(currentIndex)) } @@ -55,7 +56,7 @@ internal final class DictionaryUnkeyedDecodingContainer: @inline(__always) private func decodeNextComponent( - _ decodeComponent: (_ component: Any?, _ codingPath: [CodingKey]) throws -> T + _ decodeComponent: (_ component: consuming Any?, _ codingPath: consuming [CodingKey]) throws -> T ) throws -> T { let value = try decodeComponent(try peekNextComponent(), currentCodingPath) @@ -64,7 +65,8 @@ internal final class DictionaryUnkeyedDecodingContainer: return value } - private func superDecoder(for component: Any?, at codingPath: [CodingKey]) -> Decoder { + @inline(__always) + private func superDecoder(for component: consuming Any?, at codingPath: consuming [CodingKey]) -> Decoder { DictionarySingleValueDecodingContainer( component: component, options: options, diff --git a/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift b/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift index cd99768..8e613b2 100644 --- a/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryAnyKeyedEncodingContainer.swift @@ -22,6 +22,7 @@ internal final class DictionaryAnyKeyedEncodingContainer: DictionaryComponentCon // MARK: - Instance Methods + @inline(__always) private func encodeKey(_ key: Key) -> String { switch options.keyEncodingStrategy { case .useDefaultKeys: @@ -34,7 +35,8 @@ internal final class DictionaryAnyKeyedEncodingContainer: DictionaryComponentCon // MARK: - - internal func collectComponent(_ component: DictionaryComponent, forKey key: Key) { + @inline(__always) + internal func collectComponent(_ component: consuming DictionaryComponent, forKey key: Key) { components[encodeKey(key)] = component } diff --git a/Sources/Encoder/DictionaryComponentEncoder.swift b/Sources/Encoder/DictionaryComponentEncoder.swift index 5472cbe..e2b0592 100644 --- a/Sources/Encoder/DictionaryComponentEncoder.swift +++ b/Sources/Encoder/DictionaryComponentEncoder.swift @@ -12,8 +12,9 @@ extension DictionaryComponentEncoder { // MARK: - Instance Methods + @inline(__always) private func encodePrimitiveValue( - _ value: Any?, + _ value: consuming Any?, at codingPath: [CodingKey] ) -> DictionaryComponent { .value(value) @@ -21,7 +22,7 @@ extension DictionaryComponentEncoder { private func encodeNonPrimitiveValue( _ value: T, - at codingPath: [CodingKey] + at codingPath: consuming [CodingKey] ) throws -> DictionaryComponent { let encoder = DictionarySingleValueEncodingContainer( options: options, @@ -36,7 +37,7 @@ extension DictionaryComponentEncoder { private func encodeCustomizedValue( _ value: T, - at codingPath: [CodingKey], + at codingPath: consuming [CodingKey], closure: (_ value: T, _ encoder: Encoder) throws -> Void ) throws -> DictionaryComponent { let encoder = DictionarySingleValueEncodingContainer( @@ -60,7 +61,7 @@ extension DictionaryComponentEncoder { } } - private func encodeDate(_ date: Date, at codingPath: [CodingKey]) throws -> DictionaryComponent { + private func encodeDate(_ date: Date, at codingPath: consuming [CodingKey]) throws -> DictionaryComponent { switch options.dateEncodingStrategy { case .deferredToDate: return try encodeNonPrimitiveValue(date, at: codingPath) @@ -82,7 +83,7 @@ extension DictionaryComponentEncoder { } } - private func encodeData(_ data: Data, at codingPath: [CodingKey]) throws -> DictionaryComponent { + private func encodeData(_ data: Data, at codingPath: consuming [CodingKey]) throws -> DictionaryComponent { switch options.dataEncodingStrategy { case .deferredToData: return try encodeNonPrimitiveValue(data, at: codingPath) @@ -137,83 +138,100 @@ extension DictionaryComponentEncoder { // MARK: - + @inline(__always) internal func encodeNilComponent(at codingPath: [CodingKey]) -> DictionaryComponent { encodeNil(at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Bool, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Int, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Int8, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Int16, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Int32, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Int64, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } #if compiler(>=6.0) @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inline(__always) internal func encodeComponentValue(_ value: Int128, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } #endif + @inline(__always) internal func encodeComponentValue(_ value: UInt, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: UInt8, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: UInt16, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: UInt32, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: UInt64, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } #if compiler(>=6.0) @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @inline(__always) internal func encodeComponentValue(_ value: UInt128, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } #endif + @inline(__always) internal func encodeComponentValue(_ value: Double, at codingPath: [CodingKey]) throws -> DictionaryComponent { try encodeFloatingPoint(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: Float, at codingPath: [CodingKey]) throws -> DictionaryComponent { try encodeFloatingPoint(value, at: codingPath) } + @inline(__always) internal func encodeComponentValue(_ value: String, at codingPath: [CodingKey]) -> DictionaryComponent { encodePrimitiveValue(value, at: codingPath) } internal func encodeComponentValue( _ value: T, - at codingPath: [CodingKey] + at codingPath: consuming [CodingKey] ) throws -> DictionaryComponent { // The type is compared rather than the value cast, as a cast costs much more and is made for every value. switch ObjectIdentifier(T.self) { diff --git a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift index 24da78b..1500770 100644 --- a/Sources/Encoder/DictionarySingleValueEncodingContainer.swift +++ b/Sources/Encoder/DictionarySingleValueEncodingContainer.swift @@ -26,7 +26,8 @@ internal final class DictionarySingleValueEncodingContainer: // MARK: - Instance Methods - private func collectComponent(_ component: DictionaryComponent, for value: Any?) throws { + @inline(__always) + private func collectComponent(_ component: consuming DictionaryComponent, for value: Any?) throws { guard self.component == nil else { let errorContext = EncodingError.Context( codingPath: codingPath, diff --git a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift index 494761e..caa6a8f 100644 --- a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift @@ -11,6 +11,7 @@ internal final class DictionaryUnkeyedEncodingContainer: internal let userInfo: [CodingUserInfoKey: Any] internal let codingPath: [CodingKey] + @inline(__always) internal var currentCodingPath: [CodingKey] { codingPath.appending(AnyCodingKey(count)) } @@ -33,7 +34,8 @@ internal final class DictionaryUnkeyedEncodingContainer: // MARK: - Instance Methods - private func collectComponent(_ component: DictionaryComponent) { + @inline(__always) + private func collectComponent(_ component: consuming DictionaryComponent) { components.append(component) } diff --git a/Sources/Tools/Optional+Extensions.swift b/Sources/Tools/Optional+Extensions.swift index 141c9ac..8eefb87 100644 --- a/Sources/Tools/Optional+Extensions.swift +++ b/Sources/Tools/Optional+Extensions.swift @@ -2,6 +2,7 @@ extension Optional { // MARK: - Instance Properties + @inline(__always) internal var isNil: Bool { self == nil } diff --git a/Sources/Tools/RangeReplaceableCollection+Extensions.swift b/Sources/Tools/RangeReplaceableCollection+Extensions.swift index 06c51f1..ab2ff8d 100644 --- a/Sources/Tools/RangeReplaceableCollection+Extensions.swift +++ b/Sources/Tools/RangeReplaceableCollection+Extensions.swift @@ -2,10 +2,12 @@ extension RangeReplaceableCollection { // MARK: - Instance Methods + @inline(__always) internal func appending(contentsOf collection: T) -> Self where Self.Element == T.Element { self + collection } + @inline(__always) internal func appending(_ element: Element) -> Self { appending(contentsOf: [element]) } From 50a72b130f100451f3abca3a132d73d52ccba701 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 02:39:40 +0200 Subject: [PATCH 14/20] Remove redundant allocations and linear key lookup --- .../Decoder/DictionaryKeyedDecodingContainer.swift | 2 +- Sources/Encoder/DictionaryComponent.swift | 2 +- .../Encoder/DictionaryUnkeyedEncodingContainer.swift | 9 ++++----- .../Tools/RangeReplaceableCollection+Extensions.swift | 11 +++++------ 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index 80813ce..0087a2d 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -69,7 +69,7 @@ internal class DictionaryKeyedDecodingContainer: // MARK: - KeyedDecodingContainerProtocol internal func contains(_ key: Key) -> Bool { - components.contains { $0.key == key.stringValue } + components.keys.contains(key.stringValue) } internal func decodeNil(forKey key: Key) throws -> Bool { diff --git a/Sources/Encoder/DictionaryComponent.swift b/Sources/Encoder/DictionaryComponent.swift index b1f1a41..5420272 100644 --- a/Sources/Encoder/DictionaryComponent.swift +++ b/Sources/Encoder/DictionaryComponent.swift @@ -1,4 +1,4 @@ -internal indirect enum DictionaryComponent { +internal enum DictionaryComponent { // MARK: - Enumeration Cases diff --git a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift index caa6a8f..f8cea5f 100644 --- a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift @@ -162,11 +162,10 @@ internal final class DictionaryUnkeyedEncodingContainer: // MARK: - DictionaryComponentContainer internal func resolveValue() -> Any? { - let values = components - .lazy - .map { $0.resolveValue() } - .compactMap { $0 ?? $0 as Any } + components.map { component in + let value = component.resolveValue() - return Array(values) + return value ?? value as Any + } } } diff --git a/Sources/Tools/RangeReplaceableCollection+Extensions.swift b/Sources/Tools/RangeReplaceableCollection+Extensions.swift index ab2ff8d..62977db 100644 --- a/Sources/Tools/RangeReplaceableCollection+Extensions.swift +++ b/Sources/Tools/RangeReplaceableCollection+Extensions.swift @@ -2,13 +2,12 @@ extension RangeReplaceableCollection { // MARK: - Instance Methods - @inline(__always) - internal func appending(contentsOf collection: T) -> Self where Self.Element == T.Element { - self + collection - } - @inline(__always) internal func appending(_ element: Element) -> Self { - appending(contentsOf: [element]) + var collection = self + + collection.append(element) + + return collection } } From bf11de2487ad81ed0086312bcd64a6e4a9075ac6 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 12:18:24 +0200 Subject: [PATCH 15/20] Make keyed decoding container final --- Sources/Decoder/DictionaryKeyedDecodingContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index 0087a2d..29f3a76 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -1,4 +1,4 @@ -internal class DictionaryKeyedDecodingContainer: +internal final class DictionaryKeyedDecodingContainer: KeyedDecodingContainerProtocol, DictionaryComponentDecoder { From e51f6490965a3a10ac8b8573d2c87cee67278ac8 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 14:44:39 +0200 Subject: [PATCH 16/20] Check for NSNull by type --- DictionaryCoder.xcodeproj/project.pbxproj | 10 ---------- Sources/Decoder/DictionaryComponentDecoder.swift | 7 ++++++- Sources/Tools/Optional+Extensions.swift | 9 --------- 3 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 Sources/Tools/Optional+Extensions.swift diff --git a/DictionaryCoder.xcodeproj/project.pbxproj b/DictionaryCoder.xcodeproj/project.pbxproj index bab8936..395dfae 100644 --- a/DictionaryCoder.xcodeproj/project.pbxproj +++ b/DictionaryCoder.xcodeproj/project.pbxproj @@ -42,7 +42,6 @@ C006F1C02406B1FE00C3F783 /* DictionaryUnkeyedEncodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0723E1E0AF00F12CCB /* DictionaryUnkeyedEncodingContainer.swift */; }; C006F1C12406B20400C3F783 /* AnyCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */; }; 40B78C975BEC5E40F990D9AF /* DictionaryISO8601Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */; }; - C006F1C22406B20400C3F783 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */; }; C006F1C32406B20400C3F783 /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB0F23E1E0AF00F12CCB /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; C013EB1023E1E0AF00F12CCB /* RangeReplaceableCollection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */; }; @@ -128,9 +127,6 @@ C013EB5723E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0E23E1E0AF00F12CCB /* DictionaryComponent.swift */; }; C013EB5823E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0E23E1E0AF00F12CCB /* DictionaryComponent.swift */; }; C013EB5923E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB0E23E1E0AF00F12CCB /* DictionaryComponent.swift */; }; - C013EB5B23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */; }; - C013EB5C23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */; }; - C013EB5D23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */; }; C013EB6F23E1E2F300F12CCB /* DictionaryDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB6123E1E28700F12CCB /* DictionaryDecoderTests.swift */; }; C013EB7023E1E2F300F12CCB /* DictionaryEncoderStrategiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB5F23E1E28700F12CCB /* DictionaryEncoderStrategiesTests.swift */; }; C013EB7123E1E2F300F12CCB /* DictionaryEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C013EB6023E1E28700F12CCB /* DictionaryEncoderTests.swift */; }; @@ -215,7 +211,6 @@ C013EB0C23E1E0AF00F12CCB /* DictionaryKeyedEncodingContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryKeyedEncodingContainer.swift; sourceTree = ""; }; C013EB0D23E1E0AF00F12CCB /* DictionaryComponentEncoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryComponentEncoder.swift; sourceTree = ""; }; C013EB0E23E1E0AF00F12CCB /* DictionaryComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryComponent.swift; sourceTree = ""; }; - C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; C013EB5E23E1E28700F12CCB /* DictionaryDecoderStrategiesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryDecoderStrategiesTests.swift; sourceTree = ""; }; C013EB5F23E1E28700F12CCB /* DictionaryEncoderStrategiesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryEncoderStrategiesTests.swift; sourceTree = ""; }; C013EB6023E1E28700F12CCB /* DictionaryEncoderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryEncoderTests.swift; sourceTree = ""; }; @@ -321,7 +316,6 @@ C001ACA62F027A3B008C37A5 /* Mutex */, C013EAF323E1E0AE00F12CCB /* AnyCodingKey.swift */, 42D8B55F1A8736A8BFA545F4 /* DictionaryISO8601Style.swift */, - C013EB5A23E1E23B00F12CCB /* Optional+Extensions.swift */, C013EAF223E1E0AE00F12CCB /* RangeReplaceableCollection+Extensions.swift */, ); path = Tools; @@ -792,7 +786,6 @@ buildActionMask = 2147483647; files = ( C006F1BB2406B1FE00C3F783 /* DictionaryComponentEncoder.swift in Sources */, - C006F1C22406B20400C3F783 /* Optional+Extensions.swift in Sources */, C006F1AC2406B1F000C3F783 /* DictionaryKeyDecodingStrategy.swift in Sources */, C006F1C12406B20400C3F783 /* AnyCodingKey.swift in Sources */, 40B78C975BEC5E40F990D9AF /* DictionaryISO8601Style.swift in Sources */, @@ -833,7 +826,6 @@ C013EB5423E1E0AF00F12CCB /* DictionaryComponentEncoder.swift in Sources */, C013EB4B23E1E0AF00F12CCB /* DictionaryComponentContainer.swift in Sources */, C013EB4E23E1E0AF00F12CCB /* DictionaryEncodingOptions.swift in Sources */, - C013EB5B23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */, C013EB3C23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5723E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1223E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, @@ -885,7 +877,6 @@ C013EB5523E1E0AF00F12CCB /* DictionaryComponentEncoder.swift in Sources */, C013EB4C23E1E0AF00F12CCB /* DictionaryComponentContainer.swift in Sources */, C013EB4F23E1E0AF00F12CCB /* DictionaryEncodingOptions.swift in Sources */, - C013EB5C23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */, C013EB3D23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5823E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1323E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, @@ -937,7 +928,6 @@ C013EB5623E1E0AF00F12CCB /* DictionaryComponentEncoder.swift in Sources */, C013EB4D23E1E0AF00F12CCB /* DictionaryComponentContainer.swift in Sources */, C013EB5023E1E0AF00F12CCB /* DictionaryEncodingOptions.swift in Sources */, - C013EB5D23E1E23B00F12CCB /* Optional+Extensions.swift in Sources */, C013EB3E23E1E0AF00F12CCB /* DictionaryNonConformingFloatEncodingStrategy.swift in Sources */, C013EB5923E1E0AF00F12CCB /* DictionaryComponent.swift in Sources */, C013EB1423E1E0AF00F12CCB /* AnyCodingKey.swift in Sources */, diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index 08ca0e1..2f1ac10 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -271,7 +271,12 @@ extension DictionaryComponentDecoder { @inline(__always) internal func decodeNilComponent(from component: Any?) -> Bool { - component.isNil || component is NSNull + guard let component else { + return true + } + + // Unlike `is NSNull`, checking the type does not bridge Swift values to Objective-C objects. + return type(of: component) is NSNull.Type } @inline(__always) diff --git a/Sources/Tools/Optional+Extensions.swift b/Sources/Tools/Optional+Extensions.swift deleted file mode 100644 index 8eefb87..0000000 --- a/Sources/Tools/Optional+Extensions.swift +++ /dev/null @@ -1,9 +0,0 @@ -extension Optional { - - // MARK: - Instance Properties - - @inline(__always) - internal var isNil: Bool { - self == nil - } -} From b89e603f1a32baa28ae6164c666d5829b24a405d Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 15:28:16 +0200 Subject: [PATCH 17/20] Check the non-conforming float strategy before casting to strings --- Sources/Decoder/DictionaryComponentDecoder.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index 2f1ac10..feb3562 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -133,8 +133,8 @@ extension DictionaryComponentDecoder { from component: Any?, at codingPath: [CodingKey] ) throws -> T { - switch component { - case let string as String: + // The strategy is checked first, so that numbers are not cast to `String` with the default strategy. + if case .convertFromString = options.nonConformingFloatDecodingStrategy, let string = component as? String { switch options.nonConformingFloatDecodingStrategy { case let .convertFromString(positiveInfinity, _, _) where string == positiveInfinity: return T.infinity @@ -148,7 +148,9 @@ extension DictionaryComponentDecoder { case .convertFromString, .throw: break } + } + switch component { case let number as T where number.isFinite: return number From 9b4118e83744761141373a423c3e26eeaa9d6028 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 17:46:56 +0200 Subject: [PATCH 18/20] Reserve room for two elements in unkeyed containers --- Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift index f8cea5f..76bfb5e 100644 --- a/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift +++ b/Sources/Encoder/DictionaryUnkeyedEncodingContainer.swift @@ -36,6 +36,13 @@ internal final class DictionaryUnkeyedEncodingContainer: @inline(__always) private func collectComponent(_ component: consuming DictionaryComponent) { + // Most unkeyed containers of compact encodings hold a couple of elements, so room for two is reserved + // up front. It saves a reallocation for every container of two and more elements, which grow as usual, + // and costs memory only for containers of one element. + if components.isEmpty { + components.reserveCapacity(2) + } + components.append(component) } From 6917e1a2f3e3c60f0242e9a821d4e6c9a8a44293 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Tue, 29 Sep 2026 14:44:43 +0200 Subject: [PATCH 19/20] Look keys up once when decoding optional values --- .../DictionaryKeyedDecodingContainer.swift | 119 ++++++++++++++++++ Tests/Decoder/DictionaryDecoderTests.swift | 55 ++++++++ 2 files changed, 174 insertions(+) diff --git a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift index 29f3a76..daa0478 100644 --- a/Sources/Decoder/DictionaryKeyedDecodingContainer.swift +++ b/Sources/Decoder/DictionaryKeyedDecodingContainer.swift @@ -56,6 +56,17 @@ internal final class DictionaryKeyedDecodingContainer: return component } + // Unlike the default implementation of `decodeIfPresent`, looks the key up once rather than in `contains`, + // `decodeNil` and `decode`. + @inline(__always) + private func decodeIfPresent(forKey key: Key, _ decode: (_ component: Any) throws -> T) rethrows -> T? { + guard let component = components[key.stringValue], !decodeNilComponent(from: component) else { + return nil + } + + return try decode(component) + } + @inline(__always) private func superDecoder(forAnyKey key: CodingKey) throws -> Decoder { DictionarySingleValueDecodingContainer( @@ -150,6 +161,114 @@ internal final class DictionaryKeyedDecodingContainer: try decodeComponentValue(of: type, from: try component(forKey: key), at: codingPath.appending(key)) } + internal func decodeIfPresent(_ type: Bool.Type, forKey key: Key) throws -> Bool? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Int8.Type, forKey key: Key) throws -> Int8? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Int16.Type, forKey key: Key) throws -> Int16? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Int32.Type, forKey key: Key) throws -> Int32? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Int64.Type, forKey key: Key) throws -> Int64? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decodeIfPresent(_ type: Int128.Type, forKey key: Key) throws -> Int128? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } +#endif + + internal func decodeIfPresent(_ type: UInt.Type, forKey key: Key) throws -> UInt? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: UInt8.Type, forKey key: Key) throws -> UInt8? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: UInt16.Type, forKey key: Key) throws -> UInt16? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: UInt32.Type, forKey key: Key) throws -> UInt32? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: UInt64.Type, forKey key: Key) throws -> UInt64? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + +#if compiler(>=6.0) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + internal func decodeIfPresent(_ type: UInt128.Type, forKey key: Key) throws -> UInt128? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } +#endif + + internal func decodeIfPresent(_ type: Double.Type, forKey key: Key) throws -> Double? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: Float.Type, forKey key: Key) throws -> Float? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: String.Type, forKey key: Key) throws -> String? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(from: component, at: codingPath.appending(key)) + } + } + + internal func decodeIfPresent(_ type: T.Type, forKey key: Key) throws -> T? { + try decodeIfPresent(forKey: key) { component in + try decodeComponentValue(of: type, from: component, at: codingPath.appending(key)) + } + } + internal func nestedContainer( keyedBy keyType: NestedKey.Type, forKey key: Key diff --git a/Tests/Decoder/DictionaryDecoderTests.swift b/Tests/Decoder/DictionaryDecoderTests.swift index 253926b..506bb87 100644 --- a/Tests/Decoder/DictionaryDecoderTests.swift +++ b/Tests/Decoder/DictionaryDecoderTests.swift @@ -252,6 +252,43 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingOptionalsOfKeyedContainer() { + struct Nested: Decodable, Equatable { + let foo: Int + } + + struct DecodableStruct: Decodable, Equatable { + let int: Int? + let string: String? + let double: Double? + let nested: Nested? + let absent: Int? + let null: String? + let nullNested: Nested? + } + + let dictionary: [String: Any] = [ + "int": 1, + "string": "foo", + "double": 1.5, + "nested": ["foo": 2], + "null": NSNull(), + "nullNested": NSNull() + ] + + let value = DecodableStruct( + int: 1, + string: "foo", + double: 1.5, + nested: Nested(foo: 2), + absent: nil, + null: nil, + nullNested: nil + ) + + assertDecoderSucceeds(decoding: value, from: dictionary) + } + func testThatDecoderSucceedsWhenDecodingStructWithURL() { struct DecodableStruct: Decodable, Equatable { let foobar: URL? @@ -760,6 +797,24 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } #endif + func testThatDecoderFailsWhenDecodingOptionalOfWrongType() { + struct DecodableStruct: Decodable { + let foo: Int? + } + + let dictionary = ["foo": "bar"] + + assertDecoderFails(decoding: DecodableStruct.self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, context) where type is Int.Type: + return context.codingPath.map(\.stringValue) == ["foo"] + + default: + return false + } + } + } + func testThatDecoderFailsWhenDecodingBoolFromNumber() { let dictionary = ["foobar": 1] From 960990e4a3910a773a24d5bc62957a5f86a04c75 Mon Sep 17 00:00:00 2001 From: Dzmitry Letko Date: Mon, 5 Oct 2026 16:38:08 +0200 Subject: [PATCH 20/20] Bridge Foundation numbers, strings and data directly --- .../Decoder/DictionaryComponentDecoder.swift | 45 +++++++++++++- Tests/Decoder/DictionaryDecoderTests.swift | 58 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/Sources/Decoder/DictionaryComponentDecoder.swift b/Sources/Decoder/DictionaryComponentDecoder.swift index feb3562..1ea6ca7 100644 --- a/Sources/Decoder/DictionaryComponentDecoder.swift +++ b/Sources/Decoder/DictionaryComponentDecoder.swift @@ -16,11 +16,52 @@ extension DictionaryComponentDecoder { from component: Any?, at codingPath: [CodingKey] ) throws -> T { - guard let value = component as? T else { + guard let component else { return try decodeConvertedNumber(from: component, at: codingPath) } - return value + let componentType = Swift.type(of: component) + + if componentType == T.self, let value = component as? T { + return value + } + + if let value = bridgeFoundationComponent(component, of: componentType, to: type) { + return value + } + + if let value = component as? T { + return value + } + + return try decodeConvertedNumber(from: component, at: codingPath) + } + + // Bridging a Foundation object through `as? T` looks the bridging up on every call, + // while bridging one known to be of the class that `T` bridges from calls the same conversion directly. + @inline(__always) + private func bridgeFoundationComponent(_ component: Any, of componentType: Any.Type, to type: T.Type) -> T? { + if T.self == String.self { + guard componentType is NSString.Type, let string = component as? NSString else { + return nil + } + + return string as? T + } + + if T.self == Data.self { + guard componentType is NSData.Type, let data = component as? NSData else { + return nil + } + + return data as? T + } + + guard componentType is NSNumber.Type, let number = component as? NSNumber else { + return nil + } + + return number as? T } // Numbers of other types are converted the same way as `NSNumber`, diff --git a/Tests/Decoder/DictionaryDecoderTests.swift b/Tests/Decoder/DictionaryDecoderTests.swift index 506bb87..40580a6 100644 --- a/Tests/Decoder/DictionaryDecoderTests.swift +++ b/Tests/Decoder/DictionaryDecoderTests.swift @@ -289,6 +289,50 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { assertDecoderSucceeds(decoding: value, from: dictionary) } + func testThatDecoderSucceedsWhenDecodingFoundationNumbers() throws { + struct DecodableStruct: Decodable, Equatable { + let bool: Bool + let int: Int + let int8: Int8 + let int16: Int16 + let int32: Int32 + let int64: Int64 + let uint: UInt + let uint8: UInt8 + let uint16: UInt16 + let uint32: UInt32 + let uint64: UInt64 + let double: Double + let float: Float + let ints: [Int] + let doubles: [Double] + } + + let object: [String: Any] = [ + "bool": true, + "int": -1, + "int8": -2, + "int16": -3, + "int32": -4, + "int64": -5, + "uint": 1, + "uint8": 2, + "uint16": 3, + "uint32": 4, + "uint64": 5, + "double": 1.5, + "float": 2.5, + "ints": [1, 2, 3], + "doubles": [1.5, 2] + ] + + // Numbers of `JSONSerialization` output are `NSNumber` instances. + let json = try JSONSerialization.data(withJSONObject: object) + let dictionary = try XCTUnwrap(JSONSerialization.jsonObject(with: json) as? [String: Any]) + + assertDecoderSucceeds(decoding: DecodableStruct.self, from: dictionary) + } + func testThatDecoderSucceedsWhenDecodingStructWithURL() { struct DecodableStruct: Decodable, Equatable { let foobar: URL? @@ -815,6 +859,20 @@ final class DictionaryDecoderTests: XCTestCase, DictionaryDecoderTesting { } } + func testThatDecoderFailsWhenDecodingFoundationNumberThatDoesNotFit() { + let dictionary: [String: Any] = ["foobar": NSNumber(value: 300)] + + assertDecoderFails(decoding: [String: Int8].self, from: dictionary) { error in + switch error { + case let DecodingError.typeMismatch(type, _) where type is Int8.Type: + return true + + default: + return false + } + } + } + func testThatDecoderFailsWhenDecodingBoolFromNumber() { let dictionary = ["foobar": 1]