diff --git a/CLAUDE.md b/CLAUDE.md index 580c1f8..f593a86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ KarrotCodableKit is a Swift package that extends Swift's Codable protocol with e - **CustomCodable/**: Macro system for automated Codable implementations with CodingKey generation - **PolymorphicCodable/**: Runtime polymorphic type resolution system with strategy-based decoding - **Value Wrappers**: `PolymorphicValue`, `OptionalPolymorphicValue`, `LossyOptionalPolymorphicValue` - - **Array Wrappers**: `PolymorphicArrayValue`, `OptionalPolymorphicArrayValue`, `DefaultEmptyPolymorphicArrayValue`, `PolymorphicLossyArrayValue` + - **Array Wrappers**: `PolymorphicArrayValue`, `OptionalPolymorphicArrayValue`, `DefaultEmptyPolymorphicArrayValue`, `PolymorphicLossyArrayValue`, `OptionalPolymorphicLossyArrayValue` - Optional handles only keyNotFound/valueWasNil as nil, Lossy recovers from all errors - **AnyCodable/**: Type erasure wrappers (AnyCodable, AnyEncodable, AnyDecodable) - **BetterCodable/**: Property wrappers for common Codable patterns diff --git a/README.md b/README.md index 8429ed0..c81610d 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,9 @@ struct APIResponse { @ViewItem.PolymorphicLossyArray var lossyViewItems: [ViewItem] + + @ViewItem.OptionalPolymorphicLossyArray + var optionalLossyViewItems: [ViewItem]? } // MARK: - protocol diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift new file mode 100644 index 0000000..c3c5053 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift @@ -0,0 +1,41 @@ +// +// KeyedDecodingContainer+OptionalPolymorphicLossyArrayValue.swift +// KarrotCodableKit +// +// Created by KYHyeon on 4/6/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedDecodingContainer { + public func decode( + _ type: OptionalPolymorphicLossyArrayValue.Type, + forKey key: Key + ) throws -> OptionalPolymorphicLossyArrayValue where T: PolymorphicCodableStrategy { + if let value = try decodeIfPresent(type, forKey: key) { + return value + } else { + return OptionalPolymorphicLossyArrayValue(wrappedValue: nil, outcome: .keyNotFound) + } + } + + public func decodeIfPresent( + _ type: OptionalPolymorphicLossyArrayValue.Type, + forKey key: Self.Key + ) throws -> OptionalPolymorphicLossyArrayValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + 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) + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift new file mode 100644 index 0000000..0588cb3 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift @@ -0,0 +1,151 @@ +// +// OptionalPolymorphicLossyArrayValue.swift +// KarrotCodableKit +// +// Created by KYHyeon on 4/6/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +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``. +/// +/// 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 +/// +/// Comparison with similar wrappers: +/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing or null +/// - ``OptionalPolymorphicArrayValue``: For optional arrays that throw on invalid elements +/// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, strict on elements +/// +/// 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** +/// - Empty arrays are decoded as empty arrays, not `nil` +/// +/// Encoding behavior: +/// - If `wrappedValue` is `nil`, encodes as `null` +/// - If `wrappedValue` contains an array, each element is encoded using the `PolymorphicType` strategy +/// +@propertyWrapper +public struct OptionalPolymorphicLossyArrayValue { + /// The decoded optional array containing only the successfully decoded polymorphic elements. + /// `nil` if the key is missing or the value is `null`. + public var wrappedValue: [PolymorphicType.ExpectedType]? + + /// Tracks the outcome of the decoding process for resilient decoding + public let outcome: ResilientDecodingOutcome + + #if DEBUG + /// Results of decoding each element in the array (DEBUG only) + let results: [Result] + #endif + + public init(wrappedValue: [PolymorphicType.ExpectedType]?) { + self.wrappedValue = wrappedValue + outcome = .decodedSuccessfully + #if DEBUG + results = [] + #endif + } + + #if DEBUG + init( + wrappedValue: [PolymorphicType.ExpectedType]?, + outcome: ResilientDecodingOutcome, + results: [Result] = [] + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.results = results + } + #else + init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + #endif + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicLossyArrayProjectedValue { + PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results) + } + #endif +} + +extension OptionalPolymorphicLossyArrayValue: Decodable { + private struct AnyDecodableValue: Decodable {} + + public init(from decoder: Decoder) throws { + // First check if the value is nil + let singleValueContainer = try decoder.singleValueContainer() + if singleValueContainer.decodeNil() { + self.init(wrappedValue: nil, outcome: .valueWasNil) + return + } + + // Decode as an array with lossy behavior + var container = try decoder.unkeyedContainer() + + var elements = [PolymorphicType.ExpectedType]() + #if DEBUG + var results = [Result]() + #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 + } + } + + #if DEBUG + self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) + #else + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + #endif + } +} + +extension OptionalPolymorphicLossyArrayValue: Encodable { + public func encode(to encoder: Encoder) throws { + if let array = wrappedValue { + let polymorphicValues = array.map { + PolymorphicValue(wrappedValue: $0) + } + try polymorphicValues.encode(to: encoder) + } else { + var container = encoder.singleValueContainer() + try container.encodeNil() + } + } +} + +extension OptionalPolymorphicLossyArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension OptionalPolymorphicLossyArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + +extension OptionalPolymorphicLossyArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift b/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift index 03cd6a3..4a0d048 100644 --- a/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift +++ b/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift @@ -34,6 +34,7 @@ extension PolymorphicCodableStrategyProvidingMacro: MemberMacro { DeclSyntax("typealias PolymorphicArray = PolymorphicArrayValue<\(raw: strategyStructName)>"), DeclSyntax("typealias OptionalPolymorphicArray = OptionalPolymorphicArrayValue<\(raw: strategyStructName)>"), DeclSyntax("typealias PolymorphicLossyArray = PolymorphicLossyArrayValue<\(raw: strategyStructName)>"), + DeclSyntax("typealias OptionalPolymorphicLossyArray = OptionalPolymorphicLossyArrayValue<\(raw: strategyStructName)>"), DeclSyntax("typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue<\(raw: strategyStructName)>"), ] } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift new file mode 100644 index 0000000..42f17ce --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift @@ -0,0 +1,250 @@ +// +// OptionalPolymorphicLossyArrayValueTests.swift +// KarrotCodableKit +// +// Created by KYHyeon on 4/6/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation +import Testing + +import KarrotCodableKit + +struct OptionalPolymorphicLossyArrayValueTests { + + @Test + func decodingValidArray() throws { + // given + let jsonData = #""" + { + "notices1" : [ + { + "description" : "test1", + "icon" : "test_icon1", + "type" : "callout" + }, + { + "description" : "test2", + "action" : "https://example.com", + "type" : "actionable-callout" + } + ], + "notices2" : null + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.count == 2) + + let firstNotice = try #require(notices1[0] as? DummyCallout) + #expect(firstNotice.description == "test1") + #expect(firstNotice.icon == "test_icon1") + + let secondNotice = try #require(notices1[1] as? DummyActionableCallout) + #expect(secondNotice.description == "test2") + + #expect(result.notices2 == nil) + } + + @Test + func decodingNullValue() throws { + // given + let jsonData = #""" + { + "notices1" : null, + "notices2" : null + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices1 == nil) + #expect(result.notices2 == nil) + } + + @Test + func decodingMissingKey() throws { + // given + let jsonData = #""" + { + "notices2" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices1 == nil) + + let notices2 = try #require(result.notices2) + #expect(notices2.count == 1) + } + + @Test + func decodingEmptyArray() throws { + // given + let jsonData = #""" + { + "notices1" : [], + "notices2" : null + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices1 = try #require(result.notices1) + #expect(notices1.isEmpty) + #expect(result.notices2 == nil) + } + + @Test + func decodingWithInvalidElementSkipsIt() throws { + // given - Array with one invalid element (missing required 'description' property) + 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 - Invalid element is skipped, valid element is kept + let notices1 = try #require(result.notices1) + #expect(notices1.count == 1) + + let notice = try #require(notices1[0] as? DummyCallout) + #expect(notice.description == "test") + } + + @Test + func decodingWithAllInvalidElementsReturnsEmptyArray() throws { + // given - Array where all elements are invalid + let jsonData = #""" + { + "notices1" : [ + { + "icon" : "test_icon1", + "type" : "callout" + }, + { + "type" : "actionable-callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then - All elements are invalid, so the array is empty (not nil) + let notices1 = try #require(result.notices1) + #expect(notices1.isEmpty) + } + + @Test + func encoding() throws { + // given + let response = OptionalPolymorphicLossyArrayDummyResponse( + notices1: [ + DummyCallout( + type: .callout, + title: nil, + description: "test", + icon: "test_icon" + ), + ], + notices2: nil + ) + + let expectResult = #""" + { + "notices1" : [ + { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + ], + "notices2" : null + } + """# + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // then + let jsonString = String(decoding: data, as: UTF8.self) + #expect(jsonString == expectResult) + } + + @Test + func encodingDecodingRoundTrip() throws { + // given + let response = OptionalPolymorphicLossyArrayDummyResponse( + notices1: nil, + notices2: nil + ) + + // when - encode + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // when - decode back + let decodedResponse = try JSONDecoder().decode( + OptionalPolymorphicLossyArrayDummyResponse.self, + from: data + ) + + // then + #expect(decodedResponse.notices1 == nil) + #expect(decodedResponse.notices2 == nil) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/PolymorphicValueCodableDummy.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/PolymorphicValueCodableDummy.swift index 4bd7e8a..135becb 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/PolymorphicValueCodableDummy.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/PolymorphicValueCodableDummy.swift @@ -83,6 +83,16 @@ struct OptionalPolymorphicArrayDummyResponse { var notices2: [any DummyNotice]? } +@CustomCodable(codingKeyStyle: .snakeCase) +struct OptionalPolymorphicLossyArrayDummyResponse { + + @DummyNotice.OptionalPolymorphicLossyArray + var notices1: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicLossyArray + var notices2: [any DummyNotice]? +} + // MARK: - PolymorphicCodable @PolymorphicCodableStrategyProviding( diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift index 160b066..837551a 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift @@ -62,6 +62,8 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { typealias PolymorphicLossyArray = PolymorphicLossyArrayValue + typealias OptionalPolymorphicLossyArray = OptionalPolymorphicLossyArrayValue + typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue } @@ -130,6 +132,8 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { typealias PolymorphicLossyArray = PolymorphicLossyArrayValue + typealias OptionalPolymorphicLossyArray = OptionalPolymorphicLossyArrayValue + typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue } @@ -236,6 +240,8 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { typealias PolymorphicLossyArray = PolymorphicLossyArrayValue + typealias OptionalPolymorphicLossyArray = OptionalPolymorphicLossyArrayValue + typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue } """,