diff --git a/README.md b/README.md index c81610d..c93bab1 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,34 @@ struct UndefinedViewItem: ViewItem { } ``` +#### Enumerating a polymorphic family + +The generated strategy conforms to `PolymorphicMatchingTypesProviding`, which exposes the family as values. `decode(from:)` reads the very same properties, so the exposed list cannot drift away from what decoding resolves. + +```swift +ViewItemCodableStrategy.matchingTypes // [ImageViewItem.self, TextViewItem.self] +ViewItemCodableStrategy.fallbackType // UndefinedViewItem.self +``` + +Constrain a generic parameter to the protocol when a caller must be handed the production strategy rather than a list assembled by hand — for example, to check that every declared type has a registered handler: + +```swift +func assertEveryTypeHasHandler( + declaredIn _: Strategy.Type, + registeredIdentifiers: Set, +) { + let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier }) + #expect(declared.subtracting(registeredIdentifiers).isEmpty) +} + +assertEveryTypeHasHandler( + declaredIn: ViewItemCodableStrategy.self, + registeredIdentifiers: Set(handlers.keys), +) +``` + +`PolymorphicMatchingTypesProviding` refines `PolymorphicCodableStrategy` rather than adding requirements to it, so hand-written strategies keep working unchanged and adopt it only when they need to be enumerated. + ### PolymorphicEnumCodable `PolymorphicEnumCodable` provides a convenient way to handle polymorphic types directly in Swift enums. Unlike `PolymorphicCodable` which works with protocol-conforming types, this macro allows you to define an enum where each case contains an associated value of a different type, and enables seamless JSON encoding and decoding. diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicCodableStrategy.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicCodableStrategy.swift index 74e7ac8..9938166 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicCodableStrategy.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicCodableStrategy.swift @@ -67,6 +67,11 @@ import Foundation /// /// @PolymorphicValue /// var item: ViewItem +/// ``` +/// +/// - SeeAlso: ``PolymorphicMatchingTypesProviding``, which refines this protocol so the family can be +/// enumerated from the outside. Strategies generated by `@PolymorphicCodableStrategyProviding` +/// conform to it automatically. public protocol PolymorphicCodableStrategy { associatedtype ExpectedType static var polymorphicMetaCodingKey: CodingKey { get } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicMatchingTypesProviding.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicMatchingTypesProviding.swift new file mode 100644 index 0000000..c024e59 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicMatchingTypesProviding.swift @@ -0,0 +1,52 @@ +// +// PolymorphicMatchingTypesProviding.swift +// KarrotCodableKit +// +// Created by Elon on 8/4/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +/// A ``PolymorphicCodableStrategy`` whose polymorphic family can be enumerated from the outside. +/// +/// A plain ``PolymorphicCodableStrategy`` keeps its candidate types inside `decode(from:)`, so there is +/// no way to ask which types belong to the family. Conforming types expose that list as a value, which +/// lets callers cross-check the family against something else — for example, asserting in a test that +/// every declared type has a registered handler. +/// +/// Strategies generated by `@PolymorphicCodableStrategyProviding` adopt this protocol automatically, +/// and their `decode(from:)` reads ``matchingTypes`` and ``fallbackType`` rather than repeating the +/// list, so the exposed family and the decoded family cannot drift apart. Hand-written strategies +/// adopt it only when they need to be enumerated. +/// +/// ```swift +/// func assertEveryTypeHasHandler( +/// declaredIn _: Strategy.Type, +/// registeredIdentifiers: Set, +/// ) { +/// let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier }) +/// #expect(declared.subtracting(registeredIdentifiers).isEmpty) +/// } +/// +/// assertEveryTypeHasHandler( +/// declaredIn: ViewItemCodableStrategy.self, +/// registeredIdentifiers: Set(handlers.keys), +/// ) +/// ``` +/// +/// - Note: This protocol intentionally refines ``PolymorphicCodableStrategy`` instead of adding +/// requirements to it. A defaulted requirement on the base protocol would let a hand-written strategy +/// report an empty family, and an exhaustiveness check reading that value would pass vacuously. +public protocol PolymorphicMatchingTypesProviding: PolymorphicCodableStrategy { + /// The candidate types matched against the polymorphic identifier, in the order they are matched. + /// + /// This is the same list `decode(from:)` uses. + static var matchingTypes: [PolymorphicDecodableType.Type] { get } + + /// The type used when the identifier matches none of the ``matchingTypes``. + /// + /// `nil` means decoding fails with `PolymorphicCodableError.unableToFindPolymorphicType(_:)` + /// instead of falling back. + static var fallbackType: PolymorphicDecodableType.Type? { get } +} diff --git a/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift b/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift index 3531b7c..787ecb4 100644 --- a/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift +++ b/Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift @@ -79,7 +79,7 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro { let strategyStructName = "\(identifier)CodableStrategy" let formattedMatchingTypes = matchingTypes - .formatted(using: .init(initialIndentation: .spaces(4))) + .formatted(using: .init(initialIndentation: .spaces(2))) .trimmed let fallbackType = SyntaxHelper.findArgument(named: "fallbackType", in: arguments) @@ -87,7 +87,7 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro { return [ DeclSyntax( """ - \(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicCodableStrategy { + \(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicMatchingTypesProviding { enum PolymorphicMetaCodingKey: CodingKey { case \(raw: identifierCodingKeyString) } @@ -96,11 +96,19 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro { PolymorphicMetaCodingKey.\(raw: identifierCodingKeyString) } + \(raw: accessModifier)static var matchingTypes: [PolymorphicDecodableType.Type] { + \(raw: formattedMatchingTypes) + } + + \(raw: accessModifier)static var fallbackType: PolymorphicDecodableType.Type? { + \(raw: fallbackType ?? "nil") + } + \(raw: accessModifier)static func decode(from decoder: Decoder) throws -> any \(raw: identifier) { try decoder.decode( codingKey: Self.polymorphicMetaCodingKey, - matchingTypes: \(raw: formattedMatchingTypes), - fallbackType: \(raw: fallbackType ?? "nil") + matchingTypes: Self.matchingTypes, + fallbackType: Self.fallbackType ) } } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicMatchingTypesProvidingTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicMatchingTypesProvidingTests.swift new file mode 100644 index 0000000..9333ab3 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicMatchingTypesProvidingTests.swift @@ -0,0 +1,137 @@ +// +// PolymorphicMatchingTypesProvidingTests.swift +// KarrotCodableKit +// +// Created by Elon on 8/4/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation +import Testing + +import KarrotCodableKit + +struct PolymorphicMatchingTypesProvidingTests { + + @Test + func `generated strategy exposes its matching types in declaration order`() { + // given + // DummyNotice declares DummyCallout, DummyActionableCallout and DummyDismissibleCallout. + + // when + let identifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier } + + // then + #expect(identifiers == ["callout", "actionable-callout", "dismissible-callout"]) + } + + @Test + func `generated strategy exposes its fallback type`() throws { + // given + // DummyNotice declares DummyUndefinedCallout as its fallback. + + // when + let fallbackType = try #require(DummyNoticeCodableStrategy.fallbackType) + + // then + #expect(fallbackType.polymorphicIdentifier == "undefined-callout") + } + + @Test + func `strategy declared without a fallback type exposes nil`() { + // given + // ViewItem declares matching types only, with no fallbackType argument. + + // when + let fallbackType = ViewItemCodableStrategy.fallbackType + + // then + #expect(fallbackType == nil) + #expect(ViewItemCodableStrategy.matchingTypes.isEmpty == false) + } + + @Test + func `exposed matching types describe what decoding actually resolves`() throws { + // given + let jsonData = #""" + { + "notice" : { + "description" : "Your listing is under review", + "key" : "listing-under-review", + "title" : "Under review", + "type" : "dismissible-callout" + }, + "notices" : [ + { + "description" : "A notice type this client does not know yet", + "title" : "Sponsored", + "type" : "sponsored-callout" + } + ] + } + """# + + let declaredIdentifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier } + let fallbackIdentifier = try #require(DummyNoticeCodableStrategy.fallbackType).polymorphicIdentifier + + // when + let response = try JSONDecoder().decode(DummyResponse.self, from: Data(jsonData.utf8)) + + // then + // A declared identifier resolves to the declared type. + #expect(declaredIdentifiers.contains("dismissible-callout")) + let notice = try #require(response.notice as? DummyDismissibleCallout) + #expect(notice.key == "listing-under-review") + + // An identifier outside the exposed list resolves to the exposed fallback type. + #expect(declaredIdentifiers.contains("sponsored-callout") == false) + #expect(fallbackIdentifier == "undefined-callout") + let unknownNotice = try #require(response.notices.first as? DummyUndefinedCallout) + #expect(unknownNotice.description == "A notice type this client does not know yet") + } + + @Test + func `a generic helper can enumerate any conforming strategy`() { + // given + // A caller that only knows the protocol, mirroring an exhaustiveness check in a consumer. + + // when + let noticeIdentifiers = polymorphicIdentifiers(declaredIn: DummyNoticeCodableStrategy.self) + let viewItemIdentifiers = polymorphicIdentifiers(declaredIn: ViewItemCodableStrategy.self) + + // then + #expect(noticeIdentifiers == ["callout", "actionable-callout", "dismissible-callout"]) + #expect(viewItemIdentifiers.contains("TITLE_VIEW_ITEM")) + } + + @Test + func `a hand-written strategy keeps decoding without adopting the new protocol`() throws { + // given + let jsonData = #""" + { + "notice" : { + "description" : "Welcome to Karrot", + "icon" : "waving_hand", + "type" : "callout" + } + } + """# + + // when + let response = try JSONDecoder().decode( + HandWrittenStrategyDummyResponse.self, + from: Data(jsonData.utf8), + ) + + // then + let notice = try #require(response.notice as? DummyCallout) + #expect(notice.description == "Welcome to Karrot") + #expect(notice.icon == "waving_hand") + } +} + +private func polymorphicIdentifiers( + declaredIn _: Strategy.Type +) -> [String] { + Strategy.matchingTypes.map { $0.polymorphicIdentifier } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/HandWrittenStrategyDummy.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/HandWrittenStrategyDummy.swift new file mode 100644 index 0000000..30dfc45 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/HandWrittenStrategyDummy.swift @@ -0,0 +1,44 @@ +// +// HandWrittenStrategyDummy.swift +// KarrotCodableKit +// +// Created by Elon on 8/4/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +import KarrotCodableKit + +/// A strategy written by hand, deliberately conforming to `PolymorphicCodableStrategy` only. +/// +/// `PolymorphicMatchingTypesProviding` refines `PolymorphicCodableStrategy` instead of adding +/// requirements to it, so strategies like this one keep working untouched. This double guards that +/// promise — if the requirements ever move onto the base protocol, this file stops compiling. +struct HandWrittenNoticeCodableStrategy: PolymorphicCodableStrategy { + enum PolymorphicMetaCodingKey: CodingKey { + case type + } + + static var polymorphicMetaCodingKey: CodingKey { + PolymorphicMetaCodingKey.type + } + + static func decode(from decoder: Decoder) throws -> any DummyNotice { + try decoder.decode( + codingKey: Self.polymorphicMetaCodingKey, + matchingTypes: [ + DummyCallout.self, + DummyActionableCallout.self, + ], + fallbackType: DummyUndefinedCallout.self, + ) + } +} + +@CustomCodable(codingKeyStyle: .snakeCase) +struct HandWrittenStrategyDummyResponse { + + @PolymorphicValue + var notice: any DummyNotice +} diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift index a6805ce..f8f6f29 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift @@ -67,7 +67,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue } - public struct NoticeCodableStrategy: PolymorphicCodableStrategy { + public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding { enum PolymorphicMetaCodingKey: CodingKey { case type } @@ -76,14 +76,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { PolymorphicMetaCodingKey.type } + public static var matchingTypes: [PolymorphicDecodableType.Type] { + [ + ActionableCallout.self, + DismissibleCallout.self + ] + } + + public static var fallbackType: PolymorphicDecodableType.Type? { + UndefinedCallout.self + } + public static func decode(from decoder: Decoder) throws -> any Notice { try decoder.decode( codingKey: Self.polymorphicMetaCodingKey, - matchingTypes: [ - ActionableCallout.self, - DismissibleCallout.self - ], - fallbackType: UndefinedCallout.self + matchingTypes: Self.matchingTypes, + fallbackType: Self.fallbackType ) } } @@ -137,7 +145,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue } - public struct NoticeCodableStrategy: PolymorphicCodableStrategy { + public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding { enum PolymorphicMetaCodingKey: CodingKey { case type } @@ -146,14 +154,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { PolymorphicMetaCodingKey.type } + public static var matchingTypes: [PolymorphicDecodableType.Type] { + [ + ActionableCallout.self, + DismissibleCallout.self + ] + } + + public static var fallbackType: PolymorphicDecodableType.Type? { + nil + } + public static func decode(from decoder: Decoder) throws -> any Notice { try decoder.decode( codingKey: Self.polymorphicMetaCodingKey, - matchingTypes: [ - ActionableCallout.self, - DismissibleCallout.self - ], - fallbackType: nil + matchingTypes: Self.matchingTypes, + fallbackType: Self.fallbackType ) } }