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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
37 changes: 37 additions & 0 deletions Sources/CodableKit/Transformers/CodingTransformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
_ next: @escaping (Output) throws -> T
) -> some CodingTransformer<Input, T> {
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<WrappedOutput, T>(
defaultValue: WrappedOutput,
_ next: @escaping (WrappedOutput) throws -> T
) -> some CodingTransformer<Input, T> 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<WrappedOutput, T>(
defaultValue: WrappedOutput,
fallbackValue: T,
_ next: @escaping (WrappedOutput) throws -> T?
) -> some CodingTransformer<Input, T> where Output == WrappedOutput? {
self.wrapped(defaultValue: defaultValue)
.map(next)
.wrapped(defaultValue: fallbackValue)
}

public func paired<T>(
_ reversed: some CodingTransformer<Output, Input>
) -> some BidirectionalCodingTransformer<Input, Output> {
Expand Down
42 changes: 13 additions & 29 deletions Sources/CodableKit/Transformers/CodingTransformerComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T.Input, any Error>) -> Result<U.Output, any Error> {
transformer2.transform(transformer1.transform(input))
}
}

/// Adapts a throwing closure into a one-way transformer.
struct MapTransformer<Input, Output>: CodingTransformer {
let transformClosure: (Input) throws -> Output

func transform(_ input: Result<Input, any Error>) -> Result<Output, any Error> {
input.flatMap { value in
Result {
try transformClosure(value)
}
}
}
}

extension Chained: BidirectionalCodingTransformer
where
T: BidirectionalCodingTransformer,
Expand All @@ -51,10 +59,6 @@ where
{
let transformer: T

init(transformer: T) {
self.transformer = transformer
}

func transform(_ input: Result<T.Output, any Error>) -> Result<T.Input, any Error> {
transformer.reverseTransform(input)
}
Expand All @@ -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<Input, any Error>) -> Result<Output, any Error> {
transformer.transform(input)
}
Expand Down Expand Up @@ -135,10 +134,6 @@ enum WrappedError: Error {
struct Wrapped<T>: CodingTransformer {
let defaultValue: T?

init(defaultValue: T?) {
self.defaultValue = defaultValue
}

func transform(_ input: Result<T?, any Error>) -> Result<T, any Error> {
input.flatMap { value in
if let value {
Expand All @@ -154,8 +149,6 @@ struct Wrapped<T>: CodingTransformer {

/// Wraps a non-optional value into an optional result for further chaining.
struct Optional<T>: CodingTransformer {
init() {}

func transform(_ input: Result<T, any Error>) -> Result<T?, any Error> {
input.flatMap { value in
.success(value)
Expand All @@ -178,10 +171,6 @@ where

let transformer: T

init(transformer: T) {
self.transformer = transformer
}

func transform(_ input: Result<T.Input?, any Error>) -> Result<T.Output?, any Error> {
switch input {
case .success(.some(let value)):
Expand Down Expand Up @@ -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<Input, any Error>) -> Result<Output, any Error> {
let output = transformer.transform(input)
if case .failure(let error) = output {
Expand Down
57 changes: 57 additions & 0 deletions Tests/TransformerTests/DerivedValueTransformerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>().map { "\($0)" }
#expect(try t.transform(.success(7)).get() == "7")
}

@Test func map_propagates_upstream_failure() async throws {
let t = IdentityTransformer<Int>().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<Int>().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<Int?>().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<Int?>().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<Int?>()
.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 {
Expand Down
Loading