From 6030eba8a97fe859154a67c0f298a7bcc918580d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Wed, 15 Jul 2026 23:28:15 +0900 Subject: [PATCH 1/3] fix(polymorphic): align lossy array recovery with BetterCodable policy Lossy array wrappers scattered their recovery logic across the KeyedDecodingContainer overloads, which made behavior depend on the entry point: - decode(_:forKey:) recovered a non-array value to an empty array while decodeIfPresent(_:forKey:) propagated the error, and their release null outcomes disagreed (.valueWasNil vs .decodedSuccessfully). - OptionalPolymorphicLossyArrayValue threw on a non-array value even through plain @OptionalPolymorphicLossyArray properties, violating its lossy contract. - Decoding the wrappers directly (e.g., nested in other collections) bypassed the overloads entirely, so nothing was recovered. Align both wrappers with BetterCodable's @LossyArray: - Move null / non-array recovery into init(from:) so every entry point (decode, decodeIfPresent, direct decoding) behaves identically. The container overloads now handle only the missing-key case. - OptionalPolymorphicLossyArrayValue is now the exact optional variant of PolymorphicLossyArrayValue: it recovers a non-array value to [] and keeps nil reserved for a missing key or an explicit null. - Element failures are recorded in the decoding outcome as an ArrayDecodingError and reported per element to the resilient error reporter in DEBUG builds, matching @LossyArray. - Extract the shared lossy element loop into UnkeyedDecodingContainer.decodeLossyPolymorphicElementResults(of:). - Rewrite both wrappers' documentation to describe the actual policy. Claude-Session: https://claude.ai/code/session_017kn3Hf2khxiFy1TqimTGx9 --- ...r+OptionalPolymorphicLossyArrayValue.swift | 10 +- ...Container+PolymorphicLossyArrayValue.swift | 63 +------ ...ngContainer+LossyPolymorphicElements.swift | 35 ++++ .../OptionalPolymorphicLossyArrayValue.swift | 71 ++++---- .../PolymorphicLossyArrayValue.swift | 99 +++++++---- .../LossyArrayDecodeIfPresentTests.swift | 166 ++++++++++++++++++ .../LossyArrayRecoveryPolicyTests.swift | 163 +++++++++++++++++ ...morphicLossyArrayValueResilientTests.swift | 131 +++++++------- 8 files changed, 531 insertions(+), 207 deletions(-) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift create mode 100644 Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift index c3c5053..c1624ab 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift @@ -29,13 +29,7 @@ extension KeyedDecodingContainer { return nil } - // Check if value is null - if try decodeNil(forKey: key) { - return OptionalPolymorphicLossyArrayValue(wrappedValue: nil, outcome: .valueWasNil) - } - - // Try to decode the array with lossy behavior - let decoder = try superDecoder(forKey: key) - return try OptionalPolymorphicLossyArrayValue(from: decoder) + // Null, non-array values, and element failures are all handled inside `init(from:)`. + return try OptionalPolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift index 0960e0a..34cbfdd 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift @@ -33,42 +33,8 @@ extension KeyedDecodingContainer { #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 PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: true), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil) - #endif - } - - // Try to decode the array - do { - let decoder = try superDecoder(forKey: key) - return try PolymorphicLossyArrayValue(from: decoder) - } catch { - // If decoding fails (e.g., not an array), return empty array - #if DEBUG - return PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: false), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) - #endif - } + // Null, non-array values, and element failures are all recovered inside `init(from:)`. + return try PolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } public func decodeIfPresent( @@ -80,28 +46,7 @@ extension KeyedDecodingContainer { return nil } - // 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 PolymorphicLossyArrayValue( - wrappedValue: [], - outcome: .recoveredFrom(error, wasReported: true), - results: [] - ) - #else - return PolymorphicLossyArrayValue(wrappedValue: []) - #endif - } - - // Try to decode using PolymorphicLossyArrayValue's decoder - let decoder = try superDecoder(forKey: key) - return try PolymorphicLossyArrayValue(from: decoder) + // Null, non-array values, and element failures are all recovered inside `init(from:)`. + return try PolymorphicLossyArrayValue(from: superDecoder(forKey: key)) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift new file mode 100644 index 0000000..966ed61 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift @@ -0,0 +1,35 @@ +// +// UnkeyedDecodingContainer+LossyPolymorphicElements.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension UnkeyedDecodingContainer { + /// Decodes every element with the polymorphic `strategy`, capturing each element's result + /// instead of throwing, so lossy array wrappers can keep valid elements and record failures. + mutating func decodeLossyPolymorphicElementResults( + of strategy: Strategy.Type + ) throws -> [Result] { + var results = [Result]() + + while !isAtEnd { + // Decoding through the element's super decoder always advances the container, + // even when the element fails to decode. + let elementDecoder = try superDecoder() + do { + try results.append(.success(PolymorphicValue(from: elementDecoder).wrappedValue)) + } catch { + #if DEBUG + elementDecoder.reportError(error) + #endif + results.append(.failure(error)) + } + } + + return results + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift index f1ecd87..291dfde 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift @@ -11,23 +11,29 @@ import Foundation /// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior /// for individual elements. /// -/// This wrapper combines the optionality handling of ``OptionalPolymorphicArrayValue`` with -/// the lossy element decoding of ``PolymorphicLossyArrayValue``. +/// This is the optional variant of ``PolymorphicLossyArrayValue`` and follows the exact same +/// policy; the only difference is that a missing key or an explicit `null` is represented +/// as `nil` instead of `[]`. /// /// Key behaviors: /// - The array itself is optional (`[Element]?`), returning `nil` when the key is missing or the value is `null` /// - Invalid elements within a present array are silently skipped rather than causing decoding failure +/// - A present value that is not a valid JSON array recovers to an empty array `[]` instead of +/// throwing; `nil` stays reserved for a missing key or an explicit `null` /// /// Comparison with similar wrappers: -/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing or null +/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing, null, +/// or not a valid JSON array /// - ``OptionalPolymorphicArrayValue``: For optional arrays that throw on invalid elements /// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, -/// strict on elements +/// and fall back to `[]` entirely when any element is invalid /// /// Decoding behavior: /// - If the key is missing or the value is `null`, `wrappedValue` is set to `nil` /// - If the value is a valid array, each element is decoded using `PolymorphicValue` -/// - If an element fails to decode, the error is caught and the element is **skipped** +/// - If an element fails to decode, the error is caught and the element is **skipped**; +/// the failure is recorded in the decoding `outcome` as an `ArrayDecodingError` +/// - If the value is not a valid JSON array, the error is recovered and `wrappedValue` is set to `[]` /// - Empty arrays are decoded as empty arrays, not `nil` /// /// Encoding behavior: @@ -82,45 +88,38 @@ public struct OptionalPolymorphicLossyArrayValue]() - #endif - - while !container.isAtEnd { - do { - let value = try container.decode(PolymorphicValue.self).wrappedValue - elements.append(value) - #if DEBUG - results.append(.success(value)) - #endif - } catch { - // Decoding processing to prevent infinite loops if decoding fails. - _ = try? container.decode(AnyDecodableValue.self) - #if DEBUG - results.append(.failure(error)) - #endif + do { + var container = try decoder.unkeyedContainer() + let results = try container.decodeLossyPolymorphicElementResults(of: PolymorphicType.self) + let elements = results.compactMap(\.success) + + #if DEBUG + if results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false), results: results) + } else { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) } + #else + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + #endif + } catch { + // Same policy as `PolymorphicLossyArrayValue`: an invalid array-level value recovers to `[]`. + // `nil` stays reserved for a missing key or an explicit `null`. + #if DEBUG + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) + #endif } - - #if DEBUG - self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) - #else - self.init(wrappedValue: elements, outcome: .decodedSuccessfully) - #endif } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift index 9af5b39..1898aa7 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift @@ -12,32 +12,42 @@ import Foundation public typealias DefaultEmptyPolymorphicLossyArrayValue = PolymorphicLossyArrayValue -/// A property wrapper that decodes an array of polymorphic objects with lossy behavior for individual elements, -/// and defaults to an empty array `[]` if the array key is missing, the value is `null`, or not a valid JSON array. +/// A property wrapper that decodes an array of polymorphic objects with lossy behavior for individual +/// elements, and defaults to an empty array `[]` if the array key is missing, the value is `null`, +/// or not a valid JSON array. /// /// Decoding Behavior: /// - Attempts to decode an unkeyed container (JSON array). /// - If the container is successfully obtained, it iterates through the elements: /// - For each element, it attempts to decode using `PolymorphicValue`. /// - If an element's decoding succeeds, the resulting value is kept. -/// - If an element's decoding fails (due to any error caught by the `PolymorphicType` strategy or `PolymorphicValue`), the error is caught, logged using `print`, and the element is **skipped**. -/// - To prevent infinite loops on invalid data, it attempts to decode the failing element as `AnyDecodableValue` to advance the container's position. +/// - If an element's decoding fails (due to any error caught by the `PolymorphicType` strategy or +/// `PolymorphicValue`), the error is caught and the element is **skipped**. The failure is recorded +/// in the decoding `outcome` as an `ArrayDecodingError` and, in DEBUG builds, reported to the +/// resilient decoding error reporter — matching `@LossyArray` in BetterCodable. /// - After iterating, `wrappedValue` contains an array of only the successfully decoded elements. -/// - If the initial step of obtaining the unkeyed container fails (e.g., missing key, `null` value, wrong type), it catches the error, assigns `[]` to `wrappedValue`, and logs the error. +/// - If the value is `null` or not a valid JSON array, the error is recovered inside `init(from:)` and +/// `wrappedValue` is set to `[]`, so the recovery also applies when the wrapper is decoded directly +/// (e.g., nested in other collections). A missing key is handled by the `KeyedDecodingContainer` +/// overloads and likewise yields `[]`. /// /// Encoding Behavior: -/// - Encodes the `wrappedValue` array. Each valid element is wrapped using `PolymorphicValue` before being added to the encoded array. +/// - Encodes the `wrappedValue` array. Each valid element is wrapped using +/// `PolymorphicValue` before being added to the encoded array. /// -/// This wrapper is ideal for handling arrays where some elements might be malformed, represent unknown types, -/// or are otherwise invalid, allowing the application to process the remaining valid elements without failure. +/// This wrapper is ideal for handling arrays where some elements might be malformed, represent unknown +/// types, or are otherwise invalid, allowing the application to process the remaining valid elements +/// without failure. /// -/// **Important:** When decoding, invalid or malformed elements in the JSON array are simply omitted from the resulting array -/// rather than causing the entire decoding process to fail. This allows you to successfully process JSON arrays -/// even when they contain elements that don't conform to your expected structure or type. +/// **Important:** When decoding, invalid or malformed elements in the JSON array are simply omitted from +/// the resulting array rather than causing the entire decoding process to fail. This allows you to +/// successfully process JSON arrays even when they contain elements that don't conform to your expected +/// structure or type. /// @propertyWrapper public struct PolymorphicLossyArrayValue { - /// The decoded array containing only the successfully decoded polymorphic elements. Defaults to an empty array `[]` if the array key is missing or the value is not an array. + /// The decoded array containing only the successfully decoded polymorphic elements. + /// Defaults to an empty array `[]` if the array key is missing or the value is not an array. public var wrappedValue: [PolymorphicType.ExpectedType] /// Tracks the outcome of the decoding process for resilient decoding @@ -82,38 +92,49 @@ public struct PolymorphicLossyArrayValue]() - #endif + // Check for null first + if let singleValueContainer = try? decoder.singleValueContainer(), singleValueContainer.decodeNil() { + #if DEBUG + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Value was nil but property is non-optional" + ) + let error = DecodingError.valueNotFound([PolymorphicType.ExpectedType].self, context) + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .valueWasNil) + #endif + return + } - while !container.isAtEnd { - do { - let value = try container.decode(PolymorphicValue.self).wrappedValue - elements.append(value) - #if DEBUG - results.append(.success(value)) - #endif - } catch { - // Decoding processing to prevent infinite loops if decoding fails. - _ = try? container.decode(AnyDecodableValue.self) - #if DEBUG - results.append(.failure(error)) - #endif + do { + var container = try decoder.unkeyedContainer() + let results = try container.decodeLossyPolymorphicElementResults(of: PolymorphicType.self) + let elements = results.compactMap(\.success) + + #if DEBUG + if results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false), results: results) + } else { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) } + #else + self.init(wrappedValue: elements) + #endif + } catch { + // An invalid array-level value (e.g., not an array) recovers to an empty array. + #if DEBUG + decoder.reportError(error) + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true), results: []) + #else + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) + #endif } - - self.wrappedValue = elements - self.outcome = .decodedSuccessfully - #if DEBUG - self.results = results - #endif } + } extension PolymorphicLossyArrayValue: Encodable { diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift new file mode 100644 index 0000000..781370e --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayDecodeIfPresentTests.swift @@ -0,0 +1,166 @@ +// +// LossyArrayDecodeIfPresentTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Testing +import Foundation + +import KarrotCodableKit + +/// Characterizes the `decodeIfPresent(_:forKey:)` container overload for +/// `PolymorphicLossyArrayValue`, which synthesized `Codable` never calls for +/// `@PolymorphicLossyArray` (its `wrappedValue` is a non-optional `[T]`, so the +/// compiler-generated code always calls `decode`). +struct LossyArrayDecodeIfPresentTests { + + @Test func testDecodeIfPresentReturnsNilForMissingKey() throws { + // given + let jsonData = #"{ }"# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices1 == nil) + } + + @Test func testDecodeIfPresentReturnsEmptyArrayForNullValue() throws { + // given + let jsonData = #""" + { + "notices1" : null + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.isEmpty) + } + + @Test func testDecodeIfPresentDecodesPresentArray() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.count == 1) + #expect(notices1.wrappedValue.first?.type == .callout) + } + + @Test func testDecodeIfPresentRecoversEmptyArrayForNonArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: matches `decode(_:forKey:)`, which recovers a non-array value to an empty array + let notices1 = try #require(result.notices1) + #expect(notices1.wrappedValue.isEmpty) + } + + #if DEBUG + @Test func testDecodeIfPresentNonArrayOutcomeMatchesDecode() throws { + // given: the same non-array payload decoded through both container entry points + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let decodeResult = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + let decodeIfPresentResult = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: both entry points recover and report the same outcome + let notices1 = try #require(decodeIfPresentResult.notices1) + #expect(notices1.outcome != .decodedSuccessfully) + #expect(notices1.outcome == decodeResult.$notices1.outcome) + } + + @Test func testDecodeIfPresentNullOutcomeMatchesDecode() throws { + // given: the same null payload decoded through both container entry points + let jsonData = #""" + { + "notices1" : null + } + """# + + // when + let decodeResult = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + let decodeIfPresentResult = try JSONDecoder().decode( + LossyArrayDecodeIfPresentDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: a null value must not be reported as a clean success by either entry point + let notices1 = try #require(decodeIfPresentResult.notices1) + #expect(notices1.outcome != .decodedSuccessfully) + #expect(notices1.outcome == decodeResult.$notices1.outcome) + } + #endif +} + +/// Reaches the container-level `decodeIfPresent(_:forKey:)` overload through a manual +/// `init(from:)`, since property-wrapper synthesis never routes through it. +private struct LossyArrayDecodeIfPresentDummyResponse: Decodable { + + let notices1: PolymorphicLossyArrayValue? + + enum CodingKeys: String, CodingKey { + case notices1 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.notices1 = try container.decodeIfPresent( + PolymorphicLossyArrayValue.self, + forKey: .notices1 + ) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift new file mode 100644 index 0000000..388bb6c --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/LossyArrayRecoveryPolicyTests.swift @@ -0,0 +1,163 @@ +// +// LossyArrayRecoveryPolicyTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/15/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Testing +import Foundation + +import KarrotCodableKit + +/// Pins the shared recovery policy of the lossy array wrappers, aligned with BetterCodable's +/// `@LossyArray`: array-level failures (e.g., a non-array value) recover instead of throwing, +/// and element-level failures are recorded in the decoding `outcome`. +/// +/// `OptionalPolymorphicLossyArrayValue` follows the exact same policy as +/// `PolymorphicLossyArrayValue`; the only difference is that a missing key or an explicit +/// `null` is represented as `nil` instead of `[]`. +struct LossyArrayRecoveryPolicyTests { + + // MARK: - Array-level recovery for a non-array value + + @Test func testOptionalLossyArrayRecoversEmptyArrayForNonArrayValue() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: recovers to an empty array; `nil` stays reserved for a missing key or explicit null + #expect(try #require(result.notices1).isEmpty) + #expect(result.notices2 == nil) + } + + #if DEBUG + @Test func testOptionalLossyArrayNonArrayValueOutcomeIsRecovered() throws { + // given + let jsonData = #""" + { + "notices1" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: the recovery is visible in the outcome, matching PolymorphicLossyArrayValue + #expect(result.$notices1.error != nil) + } + #endif + + // MARK: - Direct decoding (bypasses the KeyedDecodingContainer overloads) + + @Test func testLossyArrayRecoversWhenDecodedDirectly() throws { + // given: dictionary values invoke `init(from:)` directly, bypassing container overloads + let jsonData = #""" + { + "group" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + [String: PolymorphicLossyArrayValue].self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result["group"]).wrappedValue.isEmpty) + } + + @Test func testOptionalLossyArrayRecoversWhenDecodedDirectly() throws { + // given + let jsonData = #""" + { + "group" : "not an array" + } + """# + + // when + let result = try JSONDecoder().decode( + [String: OptionalPolymorphicLossyArrayValue].self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result["group"]).wrappedValue?.isEmpty == true) + } + + // MARK: - Element failures are recorded in the outcome (as with @LossyArray) + + #if DEBUG + @Test func testLossyArrayElementFailureIsRecordedInOutcome() throws { + // given: one invalid element (missing required 'description') and one valid element + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then: valid elements are kept and the failure surfaces through the outcome + #expect(result.notices1.count == 1) + #expect(result.$notices1.error is ResilientDecodingOutcome.ArrayDecodingError) + } + + @Test func testOptionalLossyArrayElementFailureIsRecordedInOutcome() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon", + "type" : "callout" + }, + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(try #require(result.notices1).count == 1) + #expect(result.$notices1.error is ResilientDecodingOutcome.ArrayDecodingError) + } + #endif +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift index c2dcf93..0cf40b4 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift @@ -12,10 +12,10 @@ struct PolymorphicLossyArrayValueResilientTests { func emptyArray() throws { // given let json = """ - { - "notices": [] - } - """ + { + "notices": [] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -34,23 +34,23 @@ struct PolymorphicLossyArrayValueResilientTests { func successfulArrayDecoding() throws { // given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "First", - "description": "First callout", - "icon": "icon1.png" - }, - { - "type": "actionable-callout", - "title": "Second", - "description": "Second callout", - "action": "https://example.com" - } - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "First", + "description": "First callout", + "icon": "icon1.png" + }, + { + "type": "actionable-callout", + "title": "Second", + "description": "Second callout", + "action": "https://example.com" + } + ] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -73,29 +73,29 @@ struct PolymorphicLossyArrayValueResilientTests { func arrayWithInvalidElements() throws { // given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "Valid", - "description": "Valid callout", - "icon": "icon.png" - }, - { - "type": "invalid-type" - }, - { - "type": "dismissible-callout", - "title": "Also Valid", - "description": "Another valid callout", - "key": "dismiss-key" - }, - "not-an-object", - null, - 123 - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + }, + { + "type": "dismissible-callout", + "title": "Also Valid", + "description": "Another valid callout", + "key": "dismiss-key" + }, + "not-an-object", + null, + 123 + ] + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -107,7 +107,8 @@ struct PolymorphicLossyArrayValueResilientTests { #expect(result.notices[1] is DummyDismissibleCallout) #if DEBUG - #expect(result.$notices.outcome == .decodedSuccessfully) + // Element failures surface through the outcome as an ArrayDecodingError, matching @LossyArray. + #expect(result.$notices.error is ResilientDecodingOutcome.ArrayDecodingError) #expect(result.$notices.results.count == 6) // Only first and third elements succeed @@ -124,8 +125,8 @@ struct PolymorphicLossyArrayValueResilientTests { func missingKey() throws { // given let json = """ - {} - """ + {} + """ // when let data = try #require(json.data(using: .utf8)) @@ -144,10 +145,10 @@ struct PolymorphicLossyArrayValueResilientTests { func invalidType() throws { // given let json = """ - { - "notices": "not an array" - } - """ + { + "notices": "not an array" + } + """ // when let data = try #require(json.data(using: .utf8)) @@ -170,20 +171,20 @@ struct PolymorphicLossyArrayValueResilientTests { func partialErrorReporting() throws { /// given let json = """ - { - "notices": [ - { - "type": "callout", - "title": "Valid", - "description": "Valid callout", - "icon": "icon.png" - }, - { - "type": "invalid-type" - } - ] - } - """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ // when let data = try #require(json.data(using: .utf8)) From c2283fa61f9d9e9e2272b0240270e39d4c51dc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Wed, 15 Jul 2026 23:28:15 +0900 Subject: [PATCH 2/3] docs(polymorphic): correct DefaultEmptyPolymorphicArrayValue behavior docs The doc comment claimed that any invalid element causes the whole decoding to fail with a thrown error, but init(from:) catches every error and falls back to an empty array with a .recoveredFrom outcome. It also claimed errors are logged via print, while they are reported to the resilient decoding error reporter in DEBUG builds. Align the docs with the actual behavior and wrap the comment block to the 120-column limit. Claude-Session: https://claude.ai/code/session_017kn3Hf2khxiFy1TqimTGx9 --- .../DefaultEmptyPolymorphicArrayValue.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift index 40c4917..46b4cad 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift @@ -14,21 +14,30 @@ import Foundation /// Decoding Behavior: /// - It attempts to decode an unkeyed container (JSON array). /// - If the container is successfully obtained, it decodes each element using `PolymorphicValue`. -/// - **Crucially, if *any* element within the array fails to decode according to the `PolymorphicType` strategy, the entire decoding process for the wrapper fails, and an error is thrown.** This wrapper does **not** skip invalid elements. -/// - If the initial step of obtaining the unkeyed container fails (e.g., the key is missing in the parent JSON object, or the corresponding value is `null` or not an array), it catches the error, assigns `[]` to `wrappedValue`, and logs the error using `print`. +/// - **Crucially, if *any* element within the array fails to decode according to the `PolymorphicType` strategy, +/// the error is caught and the *entire* array falls back to an empty array `[]`.** This wrapper does **not** +/// skip individual invalid elements while keeping the valid ones. +/// - If obtaining the unkeyed container fails (e.g., the key is missing, the value is `null`, or the value is +/// not an array), it likewise catches the error and assigns `[]` to `wrappedValue`. +/// - Every recovered error is recorded as a `.recoveredFrom` outcome and, in DEBUG builds, reported to the +/// resilient decoding error reporter. /// /// Encoding Behavior: -/// - Encodes the `wrappedValue` array. Each element is wrapped using `PolymorphicValue` before being added to the encoded array. +/// - Encodes the `wrappedValue` array. Each element is wrapped using `PolymorphicValue` +/// before being added to the encoded array. /// -/// Use this wrapper when you expect an array that should either be present and entirely valid (according to the strategy) or completely absent/null, in which case an empty array is acceptable. -/// If you need to gracefully handle individual invalid elements within the array, use `@PolymorphicLossyArrayValue` instead. +/// Use this wrapper when you expect an array that should either be entirely valid (according to the strategy) +/// or absent/null — any failure yields an empty array rather than a decoding error. +/// If you need to gracefully handle individual invalid elements within the array, +/// use `@PolymorphicLossyArrayValue` instead. /// -/// **Note:** If you need to decode JSON arrays that may contain some invalid elements and want to ignore just those elements -/// while keeping the valid ones, use `@PolymorphicLossyArrayValue` instead of this wrapper. +/// **Note:** If you need to decode JSON arrays that may contain some invalid elements and want to ignore just +/// those elements while keeping the valid ones, use `@PolymorphicLossyArrayValue` instead of this wrapper. /// @propertyWrapper public struct DefaultEmptyPolymorphicArrayValue { - /// The decoded array of values. Defaults to an empty array `[]` if the array key is missing or decoding fails at the array level. + /// The decoded array of values. Defaults to an empty array `[]` if the array key is missing + /// or decoding fails at the array level. public var wrappedValue: [PolymorphicType.ExpectedType] /// Tracks the outcome of the decoding process for resilient decoding From b337468f6c4bdae86f81e13826d3c7bd1b7fd75d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Thu, 16 Jul 2026 00:00:40 +0900 Subject: [PATCH 3/3] perf(polymorphic): drop the intermediate Result array in release builds Address CodeRabbit feedback on #29: the lossy element helper built a full [Result] array and callers built a second array via compactMap, doubling transient storage in release builds where per-element results are unobservable. Return a LossyPolymorphicElements value instead, which carries the element array always and the per-element results only in DEBUG builds, so release decodes a lossy array with a single collection allocation. Behavior is unchanged in both configurations. Includes house-style formatting (trailing commas, redundant self) that the format hook applied to the touched files. Claude-Session: https://claude.ai/code/session_017kn3Hf2khxiFy1TqimTGx9 --- ...ngContainer+LossyPolymorphicElements.swift | 35 +++++++++++++++---- .../OptionalPolymorphicLossyArrayValue.swift | 19 +++++----- .../PolymorphicLossyArrayValue.swift | 25 +++++++------ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift index 966ed61..d3d0fcf 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/UnkeyedDecodingContainer+LossyPolymorphicElements.swift @@ -8,28 +8,49 @@ import Foundation +/// The outcome of a lossy polymorphic array pass. Per-element results are retained only in +/// DEBUG builds, so release builds allocate a single element array and no `Result` storage. +struct LossyPolymorphicElements { + let elements: [Element] + #if DEBUG + let results: [Result] + #endif +} + extension UnkeyedDecodingContainer { - /// Decodes every element with the polymorphic `strategy`, capturing each element's result - /// instead of throwing, so lossy array wrappers can keep valid elements and record failures. - mutating func decodeLossyPolymorphicElementResults( + /// Decodes every element with the polymorphic `strategy`, skipping elements that fail to + /// decode instead of throwing, so lossy array wrappers can keep the valid elements. + /// In DEBUG builds each element's `Result` is also captured for resilient decoding outcomes. + mutating func decodeLossyPolymorphicElements( of strategy: Strategy.Type - ) throws -> [Result] { + ) throws -> LossyPolymorphicElements { + var elements = [Strategy.ExpectedType]() + #if DEBUG var results = [Result]() + #endif while !isAtEnd { // Decoding through the element's super decoder always advances the container, // even when the element fails to decode. let elementDecoder = try superDecoder() do { - try results.append(.success(PolymorphicValue(from: elementDecoder).wrappedValue)) + let value = try PolymorphicValue(from: elementDecoder).wrappedValue + elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif } catch { #if DEBUG elementDecoder.reportError(error) - #endif results.append(.failure(error)) + #endif } } - return results + #if DEBUG + return LossyPolymorphicElements(elements: elements, results: results) + #else + return LossyPolymorphicElements(elements: elements) + #endif } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift index 291dfde..5819218 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift @@ -66,7 +66,7 @@ public struct OptionalPolymorphicLossyArrayValue] = [] + results: [Result] = [], ) { self.wrappedValue = wrappedValue self.outcome = outcome @@ -97,18 +97,21 @@ extension OptionalPolymorphicLossyArrayValue: Decodable { do { var container = try decoder.unkeyedContainer() - let results = try container.decodeLossyPolymorphicElementResults(of: PolymorphicType.self) - let elements = results.compactMap(\.success) + let decoded = try container.decodeLossyPolymorphicElements(of: PolymorphicType.self) #if DEBUG - if results.contains(where: \.isFailure) { - let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) - self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false), results: results) + if decoded.results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: decoded.results) + self.init( + wrappedValue: decoded.elements, + outcome: .recoveredFrom(error, wasReported: false), + results: decoded.results, + ) } else { - self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully, results: decoded.results) } #else - self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully) #endif } catch { // Same policy as `PolymorphicLossyArrayValue`: an invalid array-level value recovers to `[]`. diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift index 1898aa7..7fef80f 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift @@ -60,9 +60,9 @@ public struct PolymorphicLossyArrayValue] = [] + results: [Result] = [], ) { self.wrappedValue = wrappedValue self.outcome = outcome @@ -98,7 +98,7 @@ extension PolymorphicLossyArrayValue: Decodable { #if DEBUG let context = DecodingError.Context( codingPath: decoder.codingPath, - debugDescription: "Value was nil but property is non-optional" + debugDescription: "Value was nil but property is non-optional", ) let error = DecodingError.valueNotFound([PolymorphicType.ExpectedType].self, context) decoder.reportError(error) @@ -111,18 +111,21 @@ extension PolymorphicLossyArrayValue: Decodable { do { var container = try decoder.unkeyedContainer() - let results = try container.decodeLossyPolymorphicElementResults(of: PolymorphicType.self) - let elements = results.compactMap(\.success) + let decoded = try container.decodeLossyPolymorphicElements(of: PolymorphicType.self) #if DEBUG - if results.contains(where: \.isFailure) { - let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) - self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false), results: results) + if decoded.results.contains(where: \.isFailure) { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: decoded.results) + self.init( + wrappedValue: decoded.elements, + outcome: .recoveredFrom(error, wasReported: false), + results: decoded.results, + ) } else { - self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) + self.init(wrappedValue: decoded.elements, outcome: .decodedSuccessfully, results: decoded.results) } #else - self.init(wrappedValue: elements) + self.init(wrappedValue: decoded.elements) #endif } catch { // An invalid array-level value (e.g., not an array) recovers to an empty array.