From cc0ae48977f3f9f0bd933fb04fa4135e12467d0c Mon Sep 17 00:00:00 2001 From: Wendell Date: Tue, 16 Jun 2026 20:32:02 +0800 Subject: [PATCH] feat: add transformer map helpers --- README.md | 12 ++++ .../Transformers/CodingTransformer.swift | 37 ++++++++++++ .../CodingTransformerComposition.swift | 42 +++++--------- .../DerivedValueTransformerTests.swift | 57 +++++++++++++++++++ 4 files changed, 119 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9ea0b6a..8fd16f2 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,18 @@ Optional or defaulted derived properties fall back to `nil`/the default when the non-optional ones without a default rethrow from `init(from:)`. Add `.onFailure(_:)` to the pipeline to log failures that the fallback path would otherwise swallow. +Transformer pipelines can use `.map(_:)` for lightweight projections or scalar conversions. +For optional source values that should default before mapping, use `.map(defaultValue:_:)`. For +failable mappings that also need a default output, use `.map(defaultValue:fallbackValue:_:)`: + +```swift +@DerivedKey( + transformer: DictionaryLookupTransformer(key: "account_type") + .map(defaultValue: 0, fallbackValue: AccountType.unknown, AccountType.init(rawValue:)) +) +private(set) var accountType: AccountType = .unknown +``` + Use `@Codable(derivedFrom:)` or `@Decodable(derivedFrom:)` when several derived properties read from the same decoded source. A property-level `from:` overrides the type-level default: diff --git a/Sources/CodableKit/Transformers/CodingTransformer.swift b/Sources/CodableKit/Transformers/CodingTransformer.swift index 7f3f46b..5ff65af 100644 --- a/Sources/CodableKit/Transformers/CodingTransformer.swift +++ b/Sources/CodableKit/Transformers/CodingTransformer.swift @@ -21,6 +21,43 @@ extension CodingTransformer { Chained(transformer1: self, transformer2: next) } + /// Maps the successful output of this transformer with a closure. + /// + /// Upstream failures are propagated unchanged. If the mapping closure throws, + /// the thrown error becomes the transformed failure. + public func map( + _ next: @escaping (Output) throws -> T + ) -> some CodingTransformer { + self.chained(MapTransformer(transformClosure: next)) + } + + /// Unwraps an optional output with a default value, then maps it. + /// + /// This is equivalent to `wrapped(defaultValue:).map(_:)`, but keeps common + /// default-then-map pipelines concise. + public func map( + defaultValue: WrappedOutput, + _ next: @escaping (WrappedOutput) throws -> T + ) -> some CodingTransformer where Output == WrappedOutput? { + self.wrapped(defaultValue: defaultValue).map(next) + } + + /// Unwraps an optional output, applies a failable mapping, then unwraps the + /// mapped result with a fallback value. + /// + /// This is equivalent to + /// `wrapped(defaultValue:).map(_:) + /// .wrapped(defaultValue: fallbackValue)`. + public func map( + defaultValue: WrappedOutput, + fallbackValue: T, + _ next: @escaping (WrappedOutput) throws -> T? + ) -> some CodingTransformer where Output == WrappedOutput? { + self.wrapped(defaultValue: defaultValue) + .map(next) + .wrapped(defaultValue: fallbackValue) + } + public func paired( _ reversed: some CodingTransformer ) -> some BidirectionalCodingTransformer { diff --git a/Sources/CodableKit/Transformers/CodingTransformerComposition.swift b/Sources/CodableKit/Transformers/CodingTransformerComposition.swift index 3550dae..5e868ee 100644 --- a/Sources/CodableKit/Transformers/CodingTransformerComposition.swift +++ b/Sources/CodableKit/Transformers/CodingTransformerComposition.swift @@ -21,16 +21,24 @@ where let transformer1: T let transformer2: U - init(transformer1: T, transformer2: U) { - self.transformer1 = transformer1 - self.transformer2 = transformer2 - } - func transform(_ input: Result) -> Result { transformer2.transform(transformer1.transform(input)) } } +/// Adapts a throwing closure into a one-way transformer. +struct MapTransformer: CodingTransformer { + let transformClosure: (Input) throws -> Output + + func transform(_ input: Result) -> Result { + input.flatMap { value in + Result { + try transformClosure(value) + } + } + } +} + extension Chained: BidirectionalCodingTransformer where T: BidirectionalCodingTransformer, @@ -51,10 +59,6 @@ where { let transformer: T - init(transformer: T) { - self.transformer = transformer - } - func transform(_ input: Result) -> Result { transformer.reverseTransform(input) } @@ -81,11 +85,6 @@ where let transformer: T let reversedTransformer: U - init(transformer: T, reversedTransformer: U) { - self.transformer = transformer - self.reversedTransformer = reversedTransformer - } - func transform(_ input: Result) -> Result { transformer.transform(input) } @@ -135,10 +134,6 @@ enum WrappedError: Error { struct Wrapped: CodingTransformer { let defaultValue: T? - init(defaultValue: T?) { - self.defaultValue = defaultValue - } - func transform(_ input: Result) -> Result { input.flatMap { value in if let value { @@ -154,8 +149,6 @@ struct Wrapped: CodingTransformer { /// Wraps a non-optional value into an optional result for further chaining. struct Optional: CodingTransformer { - init() {} - func transform(_ input: Result) -> Result { input.flatMap { value in .success(value) @@ -178,10 +171,6 @@ where let transformer: T - init(transformer: T) { - self.transformer = transformer - } - func transform(_ input: Result) -> Result { switch input { case .success(.some(let value)): @@ -225,11 +214,6 @@ where let transformer: T let handler: (any Error) -> Void - init(transformer: T, handler: @escaping (any Error) -> Void) { - self.transformer = transformer - self.handler = handler - } - func transform(_ input: Result) -> Result { let output = transformer.transform(input) if case .failure(let error) = output { diff --git a/Tests/TransformerTests/DerivedValueTransformerTests.swift b/Tests/TransformerTests/DerivedValueTransformerTests.swift index db2ebe2..fa7bf26 100644 --- a/Tests/TransformerTests/DerivedValueTransformerTests.swift +++ b/Tests/TransformerTests/DerivedValueTransformerTests.swift @@ -13,6 +13,63 @@ import Testing struct DerivedValueTransformerTests { enum TestError: Error { case boom } + // MARK: - map + + @Test func map_transforms_successful_output() async throws { + let t = IdentityTransformer().map { "\($0)" } + #expect(try t.transform(.success(7)).get() == "7") + } + + @Test func map_propagates_upstream_failure() async throws { + let t = IdentityTransformer().map { "\($0)" } + var threw = false + do { _ = try t.transform(.failure(TestError.boom)).get() } catch { threw = true } + #expect(threw) + } + + @Test func map_propagates_mapping_failure() async throws { + let t = IdentityTransformer().map { _ throws -> String in + throw TestError.boom + } + + var threw = false + do { _ = try t.transform(.success(7)).get() } catch { threw = true } + #expect(threw) + } + + @Test func map_defaultValue_unwraps_optional_before_mapping() async throws { + let t = IdentityTransformer().map(defaultValue: 0) { $0 + 1 } + + #expect(try t.transform(.success(nil)).get() == 1) + #expect(try t.transform(.success(4)).get() == 5) + } + + @Test func map_defaultValue_preserves_failable_mapping_output() async throws { + enum AccountType: Int { + case normal = 1 + } + + let t = IdentityTransformer().map(defaultValue: 0, AccountType.init(rawValue:)) + + #expect(try t.transform(.success(1)).get() == .normal) + #expect(try t.transform(.success(nil)).get() == nil) + #expect(try t.transform(.success(999)).get() == nil) + } + + @Test func map_defaultValue_fallbackValue_recovers_nil_mapped_output() async throws { + enum AccountType: Int { + case unknown = 0 + case normal = 1 + } + + let t = IdentityTransformer() + .map(defaultValue: 0, fallbackValue: AccountType.unknown, AccountType.init(rawValue:)) + + #expect(try t.transform(.success(1)).get() == .normal) + #expect(try t.transform(.success(nil)).get() == .unknown) + #expect(try t.transform(.success(999)).get() == .unknown) + } + // MARK: - DictionaryLookupTransformer @Test func dictionaryLookup_hit_returns_value() async throws {