Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<PolymorphicType>`.
/// - **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<PolymorphicType>` before being added to the encoded array.
/// - Encodes the `wrappedValue` array. Each element is wrapped using `PolymorphicValue<PolymorphicType>`
/// 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<PolymorphicType: PolymorphicCodableStrategy> {
/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
Expand All @@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// UnkeyedDecodingContainer+LossyPolymorphicElements.swift
// KarrotCodableKit
//
// Created by Elon on 7/15/26.
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
//

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<Element> {
let elements: [Element]
#if DEBUG
let results: [Result<Element, Error>]
#endif
}

extension UnkeyedDecodingContainer {
/// 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<Strategy: PolymorphicCodableStrategy>(
of strategy: Strategy.Type
) throws -> LossyPolymorphicElements<Strategy.ExpectedType> {
var elements = [Strategy.ExpectedType]()
#if DEBUG
var results = [Result<Strategy.ExpectedType, Error>]()
#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 {
let value = try PolymorphicValue<Strategy>(from: elementDecoder).wrappedValue
elements.append(value)
#if DEBUG
results.append(.success(value))
#endif
} catch {
#if DEBUG
elementDecoder.reportError(error)
results.append(.failure(error))
#endif
}
}

#if DEBUG
return LossyPolymorphicElements(elements: elements, results: results)
#else
return LossyPolymorphicElements(elements: elements)
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PolymorphicType>`
/// - 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:
Expand Down Expand Up @@ -60,7 +66,7 @@ public struct OptionalPolymorphicLossyArrayValue<PolymorphicType: PolymorphicCod
init(
wrappedValue: [PolymorphicType.ExpectedType]?,
outcome: ResilientDecodingOutcome,
results: [Result<PolymorphicType.ExpectedType, Error>] = []
results: [Result<PolymorphicType.ExpectedType, Error>] = [],
) {
self.wrappedValue = wrappedValue
self.outcome = outcome
Expand All @@ -82,45 +88,41 @@ public struct OptionalPolymorphicLossyArrayValue<PolymorphicType: PolymorphicCod
}

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() {
if let singleValueContainer = try? decoder.singleValueContainer(), 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<PolymorphicType.ExpectedType, Error>]()
#endif

while !container.isAtEnd {
do {
let value = try container.decode(PolymorphicValue<PolymorphicType>.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 decoded = try container.decodeLossyPolymorphicElements(of: PolymorphicType.self)

#if DEBUG
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: decoded.elements, outcome: .decodedSuccessfully, results: decoded.results)
}
#else
self.init(wrappedValue: decoded.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
}
}

Expand Down
Loading
Loading