diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift index 4e1d614..050004e 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift @@ -58,9 +58,15 @@ extension OptionalDateValue: Decodable where Formatter.RawValue: Decodable { #endif throw error } + + } catch DecodingError.keyNotFound { + self.wrappedValue = nil + self.outcome = .keyNotFound + } catch DecodingError.valueNotFound(let rawType, _) where rawType == Formatter.RawValue.self { self.wrappedValue = nil self.outcome = .valueWasNil + } catch { #if DEBUG decoder.reportError(error) @@ -96,20 +102,38 @@ extension KeyedDecodingContainer { _ type: OptionalDateValue.Type, forKey key: Self.Key ) throws -> OptionalDateValue where T.RawValue: Decodable { - try decodeIfPresent(type, forKey: key) ?? OptionalDateValue(wrappedValue: nil) + // Check if the key exists + guard contains(key) else { + return OptionalDateValue(wrappedValue: nil, outcome: .keyNotFound) + } + + // Check if the value is null + if try decodeNil(forKey: key) { + return OptionalDateValue(wrappedValue: nil, outcome: .valueWasNil) + } + + // Try to decode using the generic approach + let value = try decodeIfPresent(type, forKey: key) + return value ?? OptionalDateValue(wrappedValue: nil, outcome: .keyNotFound) } public func decodeIfPresent( _ type: OptionalDateValue.Type, - forKey key: Self.Key - ) throws -> OptionalDateValue where T.RawValue == String { - let stringOptionalValue = try decodeIfPresent(String.self, forKey: key) + forKey key: Self.Key, + ) throws -> OptionalDateValue? where T.RawValue == String { + // Check if the key exists + guard contains(key) else { + return nil + } - guard let stringValue = stringOptionalValue else { - return .init(wrappedValue: nil) + // Check if the value is null + if try decodeNil(forKey: key) { + return OptionalDateValue(wrappedValue: nil, outcome: .valueWasNil) } + // Try to decode the string value + let stringValue = try decode(String.self, forKey: key) let dateValue = try T.decode(stringValue) - return .init(wrappedValue: dateValue) + return OptionalDateValue(wrappedValue: dateValue, outcome: .decodedSuccessfully) } } diff --git a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift index 02d83c5..4749cad 100644 --- a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift +++ b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift @@ -110,230 +110,3 @@ extension DefaultCodable: Hashable where Default.DefaultValue: Hashable { } extension DefaultCodable: Sendable where Default.DefaultValue: Sendable {} - -// MARK: - KeyedDecodingContainer - -public protocol BoolCodableStrategy: DefaultCodableStrategy where DefaultValue == Bool {} - -extension KeyedDecodingContainer { - /// Default implementation of decoding a DefaultCodable - /// - /// Decodes successfully if key is available if not fallback to the default value provided. - public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ - // Check if key exists - if !contains(key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Key not found but property is non-optional" - ) - let error = DecodingError.keyNotFound(key, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - // Check for nil - if (try? decodeNil(forKey: key)) == true { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound(P.DefaultValue.self, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - // Try to decode normally - if let value = try decodeIfPresent(DefaultCodable

.self, forKey: key) { - return value - } else { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Key not found but property is non-optional" - ) - let error = DecodingError.keyNotFound(key, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - } - - /// Default implementation of decoding a `DefaultCodable` where its strategy is a `BoolCodableStrategy`. - /// - /// Tries to initially Decode a `Bool` if available, otherwise tries to decode it as an `Int` or `String` - /// when there is a `typeMismatch` decoding error. This preserves the actual value of the `Bool` in which - /// the data provider might be sending the value as different types. If everything fails defaults to - /// the `defaultValue` provided by the strategy. - public func decode(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ - // Check if key exists - if !contains(key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Key not found but property is non-optional" - ) - let error = DecodingError.keyNotFound(key, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - // Check for nil first - if (try? decodeNil(forKey: key)) == true { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound(Bool.self, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - do { - let value = try decode(Bool.self, forKey: key) - return DefaultCodable(wrappedValue: value) - } catch { - guard - let decodingError = error as? DecodingError, - case .typeMismatch = decodingError - else { - // Report error and use default - #if DEBUG - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - if - let intValue = try? decodeIfPresent(Int.self, forKey: key), - let bool = Bool(exactly: NSNumber(value: intValue)) - { - return DefaultCodable(wrappedValue: bool) - } else if - let stringValue = try? decodeIfPresent(String.self, forKey: key), - let bool = Bool(stringValue) - { - return DefaultCodable(wrappedValue: bool) - } else { - // Type mismatch - report error - #if DEBUG - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(decodingError) - return DefaultCodable( - wrappedValue: P.defaultValue, - outcome: .recoveredFrom(decodingError, wasReported: decoder != nil) - ) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - } - } - - /// Decodes a DefaultCodable where the strategy's DefaultValue is RawRepresentable - /// - /// This method provides special handling for RawRepresentable types: - /// - If `isFrozen` is false (default), unknown raw values result in UnknownNovelValueError and use the default value - /// - If `isFrozen` is true, unknown raw values result in DecodingError and use the default value - public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

- where P.DefaultValue: RawRepresentable, P.DefaultValue.RawValue: Decodable - { - // Check if key exists - if !contains(key) { - #if DEBUG - let context = DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Key not found") - let error = DecodingError.keyNotFound(key, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - // Check for nil - if (try? decodeNil(forKey: key)) == true { - #if DEBUG - let context = DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Value was nil") - let error = DecodingError.valueNotFound(P.self, context) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - - // Try to decode the raw value - do { - let rawValue = try decode(P.DefaultValue.RawValue.self, forKey: key) - - // Try to create the enum from raw value - if let value = P.DefaultValue(rawValue: rawValue) { - return DefaultCodable(wrappedValue: value) - } else { - #if DEBUG - /// Unknown raw value - let error = Self.createUnknownRawValueError( - for: P.DefaultValue.self, - rawValue: rawValue, - codingPath: codingPath + [key], - isFrozen: P.isFrozen - ) - - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - } catch { - #if DEBUG - /// Decoding the raw value failed (e.g., type mismatch) - let decoder = try? superDecoder(forKey: key) - decoder?.reportError(error) - return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: decoder != nil)) - #else - return DefaultCodable(wrappedValue: P.defaultValue) - #endif - } - } - - private static func createUnknownRawValueError( - for type: T.Type, - rawValue: T.RawValue, - codingPath: [CodingKey], - isFrozen: Bool - ) -> Error { - guard isFrozen else { return UnknownNovelValueError(novelValue: rawValue) } - let context = DecodingError.Context( - codingPath: codingPath, - debugDescription: "Cannot initialize \(type) from invalid raw value \(rawValue)" - ) - return DecodingError.dataCorrupted(context) - } -} diff --git a/Sources/KarrotCodableKit/BetterCodable/Defaults/Extenstions/KeyedDecodingContainer+DefaultCodable.swift b/Sources/KarrotCodableKit/BetterCodable/Defaults/Extenstions/KeyedDecodingContainer+DefaultCodable.swift new file mode 100644 index 0000000..89b543e --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/Defaults/Extenstions/KeyedDecodingContainer+DefaultCodable.swift @@ -0,0 +1,203 @@ +// +// KeyedDecodingContainer+DefaultCodable.swift +// KarrotCodableKit +// +// Created by elon on 9/23/25. +// + +import Foundation + +public protocol BoolCodableStrategy: DefaultCodableStrategy where DefaultValue == Bool {} + +extension KeyedDecodingContainer { + /// Default implementation of decoding a DefaultCodable + /// + /// Decodes successfully if key is available if not fallback to the default value provided. + public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode normally + if let value = try decodeIfPresent(DefaultCodable

.self, forKey: key) { + return value + } else { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + } + + /// Default implementation of decoding a `DefaultCodable` where its strategy is a `BoolCodableStrategy`. + /// + /// Tries to initially Decode a `Bool` if available, otherwise tries to decode it as an `Int` or `String` + /// when there is a `typeMismatch` decoding error. This preserves the actual value of the `Bool` in which + /// the data provider might be sending the value as different types. If everything fails defaults to + /// the `defaultValue` provided by the strategy. + public func decode(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil first + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + do { + let value = try decode(Bool.self, forKey: key) + return DefaultCodable(wrappedValue: value) + } catch { + guard + let decodingError = error as? DecodingError, + case .typeMismatch = decodingError + else { + // Report error and use default + #if DEBUG + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(error) + return DefaultCodable( + wrappedValue: P.defaultValue, + outcome: .recoveredFrom(error, wasReported: decoder != nil), + ) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + if + let intValue = try? decodeIfPresent(Int.self, forKey: key), + let bool = Bool(exactly: NSNumber(value: intValue)) + { + return DefaultCodable(wrappedValue: bool) + } + + if + let stringValue = try? decodeIfPresent(String.self, forKey: key), + let bool = Bool(stringValue) + { + return DefaultCodable(wrappedValue: bool) + } + + // Type mismatch - report error + #if DEBUG + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(decodingError) + return DefaultCodable( + wrappedValue: P.defaultValue, + outcome: .recoveredFrom(decodingError, wasReported: decoder != nil), + ) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + } + + /// Decodes a DefaultCodable where the strategy's DefaultValue is RawRepresentable + /// + /// This method provides special handling for RawRepresentable types: + /// - If `isFrozen` is false (default), unknown raw values result in UnknownNovelValueError and use the default value + /// - If `isFrozen` is true, unknown raw values result in DecodingError and use the default value + public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

+ where P.DefaultValue: RawRepresentable, P.DefaultValue.RawValue: Decodable + { + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode the raw value + do { + let rawValue = try decode(P.DefaultValue.RawValue.self, forKey: key) + + // Try to create the enum from raw value + if let value = P.DefaultValue(rawValue: rawValue) { + return DefaultCodable(wrappedValue: value) + } + + #if DEBUG + /// Unknown raw value + let error = Self.createUnknownRawValueError( + for: P.DefaultValue.self, + rawValue: rawValue, + codingPath: codingPath + [key], + isFrozen: P.isFrozen, + ) + + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(error) + return DefaultCodable( + wrappedValue: P.defaultValue, + outcome: .recoveredFrom(error, wasReported: decoder != nil) + ) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + + } catch { + #if DEBUG + /// Decoding the raw value failed (e.g., type mismatch) + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(error) + return DefaultCodable( + wrappedValue: P.defaultValue, + outcome: .recoveredFrom(error, wasReported: decoder != nil) + ) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + } + + private static func createUnknownRawValueError( + for type: T.Type, + rawValue: T.RawValue, + codingPath: [CodingKey], + isFrozen: Bool, + ) -> Error { + guard isFrozen else { return UnknownNovelValueError(novelValue: rawValue) } + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: "Cannot initialize \(type) from invalid raw value \(rawValue)", + ) + return DecodingError.dataCorrupted(context) + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift index 5358e7c..25d1cf7 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift @@ -10,39 +10,17 @@ import Foundation extension KeyedDecodingContainer { public func decode( - _: DefaultEmptyPolymorphicArrayValue.Type, + _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Key ) throws -> DefaultEmptyPolymorphicArrayValue where T: PolymorphicCodableStrategy { // Check if key exists guard contains(key) else { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Key not found but property is non-optional" - ) - let error = DecodingError.keyNotFound(key, context) - let decoder = try superDecoder(forKey: key) - decoder.reportError(error) - return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true)) - #else return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .keyNotFound) - #endif } // Check if value is null if try decodeNil(forKey: key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound([Any].self, context) - let decoder = try superDecoder(forKey: key) - decoder.reportError(error) - return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true)) - #else return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) - #endif } // Try to decode using the property wrapper's decoder @@ -51,7 +29,7 @@ extension KeyedDecodingContainer { } public func decodeIfPresent( - _: DefaultEmptyPolymorphicArrayValue.Type, + _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Self.Key ) throws -> DefaultEmptyPolymorphicArrayValue? where T: PolymorphicCodableStrategy { // Check if key exists @@ -61,18 +39,7 @@ extension KeyedDecodingContainer { // Check if value is null if try decodeNil(forKey: key) { - #if DEBUG - let context = DecodingError.Context( - codingPath: codingPath + [key], - debugDescription: "Value was nil but property is non-optional" - ) - let error = DecodingError.valueNotFound([Any].self, context) - let decoder = try superDecoder(forKey: key) - decoder.reportError(error) - return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true)) - #else return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) - #endif } // Try to decode using the property wrapper's decoder diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift index 43c0227..a584279 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift @@ -64,6 +64,15 @@ extension LossyOptionalPolymorphicValue: Decodable { do { self.wrappedValue = try PolymorphicType.decode(from: decoder) self.outcome = .decodedSuccessfully + + } catch DecodingError.keyNotFound { + self.wrappedValue = nil + self.outcome = .keyNotFound + + } catch DecodingError.valueNotFound(let rawType, _) where rawType == PolymorphicType.ExpectedType.self { + self.wrappedValue = nil + self.outcome = .valueWasNil + } catch { #if DEBUG // Report error to resilient decoding error reporter diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift index 396daf6..af50caa 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift @@ -8,14 +8,15 @@ import Foundation -/// A property wrapper for decoding an optional polymorphic object that throws errors on decoding failure. +/// A property wrapper for decoding an optional polymorphic object with selective error handling. /// /// This wrapper attempts to decode a single polymorphic value using the provided `PolymorphicType` strategy. -/// Unlike `@LossyOptionalPolymorphicValue`, if the `PolymorphicType.decode(from:)` method throws *any* error during decoding -/// (e.g., missing identifier key, unknown identifier value, invalid data for the concrete type, or even a missing key for the value itself), -/// this wrapper **re-throws the error** instead of providing a default value. +/// Unlike `@LossyOptionalPolymorphicValue`, this wrapper handles only specific decoding failures gracefully: +/// - `DecodingError.keyNotFound`: Sets `wrappedValue` to `nil` with outcome `.keyNotFound` +/// - `DecodingError.valueNotFound` (when the expected type matches): Sets `wrappedValue` to `nil` with outcome `.valueWasNil` +/// - All other errors (e.g. unknown identifier value, invalid data for the concrete type): **Re-throws the error** /// -/// **Note:** If you need error-tolerant decoding that assigns `nil` on failure, use `@LossyOptionalPolymorphicValue` instead. +/// **Note:** If you need fully error-tolerant decoding that always assigns `nil` on any failure, use `@LossyOptionalPolymorphicValue` instead. /// /// Encoding behavior: /// - If `wrappedValue` is `nil`, it encodes nothing (or `null` if used in an unkeyed container context where nulls are explicit). @@ -61,6 +62,15 @@ extension OptionalPolymorphicValue: Decodable { do { self.wrappedValue = try PolymorphicType.decode(from: decoder) self.outcome = .decodedSuccessfully + + } catch DecodingError.keyNotFound { + self.wrappedValue = nil + self.outcome = .keyNotFound + + } catch DecodingError.valueNotFound(let rawType, _) where rawType == PolymorphicType.ExpectedType.self { + self.wrappedValue = nil + self.outcome = .valueWasNil + } catch { // OptionalPolymorphicValue throws errors instead of recovering throw error diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueResilientTests.swift new file mode 100644 index 0000000..ee9eeea --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueResilientTests.swift @@ -0,0 +1,318 @@ +// +// OptionalDateValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 9/23/25. +// + +import Foundation +import KarrotCodableKit +import Testing + +@Suite("OptionalDateValue Resilient Decoding") +struct OptionalDateValueResilientTests { + struct ISO8601Fixture: Decodable { + @OptionalDateValue var dateValue: Date? + } + + struct TimestampFixture: Decodable { + @OptionalDateValue var dateValue: Date? + } + + // MARK: - ISO8601Strategy Tests + + @Test("ISO8601Strategy: missing key sets outcome to keyNotFound") + func iso8601MissingKeyOutcome() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(ISO8601Fixture.self, from: data) + + // Should decode successfully with nil value + #expect(fixture.dateValue == nil) + + #if DEBUG + // Outcome should be keyNotFound for missing keys + #expect(fixture.$dateValue.outcome == .keyNotFound) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("ISO8601Strategy: null value sets outcome to valueWasNil") + func iso8601NullValueOutcome() throws { + let json = """ + { + "dateValue": null + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(ISO8601Fixture.self, from: data) + + // Should decode successfully with nil value + #expect(fixture.dateValue == nil) + + #if DEBUG + // Outcome should be valueWasNil for null values + #expect(fixture.$dateValue.outcome == .valueWasNil) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("ISO8601Strategy: valid value sets outcome to decodedSuccessfully") + func iso8601ValidValueOutcome() throws { + let json = """ + { + "dateValue": "1996-12-19T16:39:57-08:00" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(ISO8601Fixture.self, from: data) + + // Should decode successfully with expected date + let expectedDate = Date(timeIntervalSince1970: 851042397) + #expect(fixture.dateValue == expectedDate) + + #if DEBUG + // Outcome should be decodedSuccessfully + #expect(fixture.$dateValue.outcome == .decodedSuccessfully) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("ISO8601Strategy: invalid format throws error") + func iso8601InvalidFormatThrows() throws { + let json = """ + { + "dateValue": "invalid-date-format" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + + // Should throw error for invalid format + #expect(throws: DecodingError.self) { + try decoder.decode(ISO8601Fixture.self, from: data) + } + } + + @Test("ISO8601Strategy: type mismatch throws error") + func iso8601TypeMismatchThrows() throws { + let json = """ + { + "dateValue": 123456789 + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + + // Should throw error for type mismatch + #expect(throws: DecodingError.self) { + try decoder.decode(ISO8601Fixture.self, from: data) + } + } + + // MARK: - TimestampStrategy Tests + + @Test("TimestampStrategy: missing key sets outcome to keyNotFound") + func timestampMissingKeyOutcome() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(TimestampFixture.self, from: data) + + // Should decode successfully with nil value + #expect(fixture.dateValue == nil) + + #if DEBUG + // Outcome should be keyNotFound for missing keys + #expect(fixture.$dateValue.outcome == .keyNotFound) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("TimestampStrategy: null value sets outcome to valueWasNil") + func timestampNullValueOutcome() throws { + let json = """ + { + "dateValue": null + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(TimestampFixture.self, from: data) + + // Should decode successfully with nil value + #expect(fixture.dateValue == nil) + + #if DEBUG + // Outcome should be valueWasNil for null values + #expect(fixture.$dateValue.outcome == .valueWasNil) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("TimestampStrategy: valid value sets outcome to decodedSuccessfully") + func timestampValidValueOutcome() throws { + let json = """ + { + "dateValue": 851042397.0 + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(TimestampFixture.self, from: data) + + // Should decode successfully with expected date + let expectedDate = Date(timeIntervalSince1970: 851042397.0) + #expect(fixture.dateValue == expectedDate) + + #if DEBUG + // Outcome should be decodedSuccessfully + #expect(fixture.$dateValue.outcome == .decodedSuccessfully) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("TimestampStrategy: integer timestamp works") + func timestampIntegerValueOutcome() throws { + let json = """ + { + "dateValue": 851042397 + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(TimestampFixture.self, from: data) + + // Should decode successfully with expected date + let expectedDate = Date(timeIntervalSince1970: 851042397.0) + #expect(fixture.dateValue == expectedDate) + + #if DEBUG + // Outcome should be decodedSuccessfully + #expect(fixture.$dateValue.outcome == .decodedSuccessfully) + #expect(fixture.$dateValue.error == nil) + #endif + } + + @Test("TimestampStrategy: type mismatch throws error") + func timestampTypeMismatchThrows() throws { + let json = """ + { + "dateValue": "not-a-number" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + + // Should throw error for type mismatch + #expect(throws: DecodingError.self) { + try decoder.decode(TimestampFixture.self, from: data) + } + } + + // MARK: - Direct Decoder Tests (Resilient Behavior Works) + + @Test("Direct single value decoding with nil works correctly") + func directSingleValueDecodingNil() throws { + // When decoding directly from a single value container, resilient behavior works + let json = "null" + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + + let dateValue = try decoder.decode(OptionalDateValue.self, from: data) + + #expect(dateValue.wrappedValue == nil) + + #if DEBUG + // Direct decoding properly sets .valueWasNil for null values + #expect(dateValue.outcome == .valueWasNil) + #expect(dateValue.projectedValue.error == nil) + #endif + } + + @Test("Direct single value decoding with missing key throws error") + func directSingleValueDecodingMissingKey() throws { + // When a key is truly missing in single value context, it throws + let json = "{}" + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + + #expect(throws: DecodingError.self) { + _ = try decoder.decode(OptionalDateValue.self, from: data) + } + } + + // MARK: - Mixed Strategy Tests + + @Test("Combined strategies work correctly in same fixture") + func combinedStrategiesOutcome() throws { + struct CombinedFixture: Decodable { + @OptionalDateValue var isoDate: Date? + @OptionalDateValue var timestampDate: Date? + } + + let json = """ + { + "isoDate": "1996-12-19T16:39:57-08:00", + "timestampDate": null + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(CombinedFixture.self, from: data) + + // Should decode ISO date successfully and timestamp as nil + let expectedDate = Date(timeIntervalSince1970: 851042397) + #expect(fixture.isoDate == expectedDate) + #expect(fixture.timestampDate == nil) + + #if DEBUG + // Outcomes should be correct for each strategy + #expect(fixture.$isoDate.outcome == .decodedSuccessfully) + // Null values should set .valueWasNil + #expect(fixture.$timestampDate.outcome == .valueWasNil) + #expect(fixture.$isoDate.error == nil) + #expect(fixture.$timestampDate.error == nil) + #endif + } + + @Test("All missing keys scenario") + func allMissingKeysOutcome() throws { + struct CombinedFixture: Decodable { + @OptionalDateValue var isoDate: Date? + @OptionalDateValue var timestampDate: Date? + } + + let json = "{}" + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(CombinedFixture.self, from: data) + + // Both should be nil + #expect(fixture.isoDate == nil) + #expect(fixture.timestampDate == nil) + + #if DEBUG + // Missing keys should set .keyNotFound for both + #expect(fixture.$isoDate.outcome == .keyNotFound) + #expect(fixture.$timestampDate.outcome == .keyNotFound) + #expect(fixture.$isoDate.error == nil) + #expect(fixture.$timestampDate.error == nil) + #endif + } +} \ No newline at end of file diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift index b92de5f..5b07174 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift @@ -78,11 +78,12 @@ struct DefaultCodableResilientTests { #expect(fixture.dictValue == [:]) #if DEBUG - #expect(fixture.$intValue.error != nil) - #expect(fixture.$stringValue.error != nil) - #expect(fixture.$boolValue.error != nil) - #expect(fixture.$arrayValue.error != nil) - #expect(fixture.$dictValue.error != nil) + // No error when key is missing (default behavior) + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) #endif } @@ -143,11 +144,12 @@ struct DefaultCodableResilientTests { #expect(fixture.dictValue == [:]) #if DEBUG - #expect(fixture.$intValue.error != nil) - #expect(fixture.$stringValue.error != nil) - #expect(fixture.$boolValue.error != nil) - #expect(fixture.$arrayValue.error != nil) - #expect(fixture.$dictValue.error != nil) + // null is not considered an error + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) #endif } @@ -340,20 +342,17 @@ struct DefaultCodableResilientTests { // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! - let (fixture, errorDigest) = try decoder.decode( - RawRepresentableFixture.self, - from: data, - reportResilientDecodingErrors: true - ) + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) // then #expect(fixture.normalEnum == .unknown) #expect(fixture.frozenEnum == .fallback) #if DEBUG - #expect(errorDigest != nil) - #expect(fixture.$normalEnum.error != nil) - #expect(fixture.$frozenEnum.error != nil) + #expect(fixture.$normalEnum.outcome == .keyNotFound) + #expect(fixture.$frozenEnum.outcome == .keyNotFound) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) #endif } @@ -370,20 +369,17 @@ struct DefaultCodableResilientTests { // when let decoder = JSONDecoder() let data = json.data(using: .utf8)! - let (fixture, errorDigest) = try decoder.decode( - RawRepresentableFixture.self, - from: data, - reportResilientDecodingErrors: true - ) + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) // then #expect(fixture.normalEnum == .unknown) #expect(fixture.frozenEnum == .fallback) #if DEBUG - #expect(errorDigest != nil) - #expect(fixture.$normalEnum.error != nil) - #expect(fixture.$frozenEnum.error != nil) + #expect(fixture.$normalEnum.outcome == .valueWasNil) + #expect(fixture.$frozenEnum.outcome == .valueWasNil) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) #endif } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift index 4392bc8..d87f4ec 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift @@ -116,8 +116,8 @@ struct DefaultEmptyPolymorphicArrayValueResilientTests { // then #expect(result.notices.isEmpty) #if DEBUG - // missing key should error for non-optional property - #expect(result.$notices.error != nil) + #expect(result.$notices.outcome == .keyNotFound) + #expect(result.$notices.error == nil) #endif } @@ -137,8 +137,8 @@ struct DefaultEmptyPolymorphicArrayValueResilientTests { // then #expect(result.notices.isEmpty) #if DEBUG - // null value should error for non-optional property - #expect(result.$notices.error != nil) + #expect(result.$notices.outcome == .valueWasNil) + #expect(result.$notices.error == nil) #endif }