Skip to content

Commit e9ce48f

Browse files
committed
Merge branch 'lossless-strategy'
2 parents 63371a6 + fefdc45 commit e9ce48f

File tree

4 files changed

+194
-53
lines changed

4 files changed

+194
-53
lines changed

Sources/BetterCodable/DefaultCodable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Foundation
77
public protocol DefaultCodableStrategy {
88
associatedtype DefaultValue: Decodable
99

10+
/// The fallback value used when decoding fails
1011
static var defaultValue: DefaultValue { get }
1112
}
1213

Sources/BetterCodable/LosslessValue.swift

Lines changed: 129 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,84 +2,166 @@ import Foundation
22

33
public typealias LosslessStringCodable = LosslessStringConvertible & Codable
44

5+
/// Provides an ordered list of types for decoding the lossless value, prioritizing the first type that successfully decodes as the inferred type.
6+
///
7+
/// `LosslessDecodingStrategy` provides a generic strategy that the `LosslessValueCodable` property wrapper can use to provide
8+
/// the ordered list of decodable types in order to maximize preservation for the inferred type.
9+
public protocol LosslessDecodingStrategy {
10+
associatedtype Value: LosslessStringCodable
11+
12+
/// An ordered list of decodable scenarios used to infer the encoded type
13+
static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] { get }
14+
}
15+
516
/// Decodes Codable values into their respective preferred types.
617
///
7-
/// `@LosslessValue` attempts to decode Codable types into their respective preferred types while preserving the data.
18+
/// `@LosslessValueCodable` attempts to decode Codable types into their preferred order while preserving the data in the most lossless format.
819
///
9-
/// This is useful when data may return unpredictable values when a consumer is expecting a certain type. For instance,
10-
/// if an API sends SKUs as either an `Int` or `String`, then a `@LosslessValue` can ensure the types are always decoded
11-
/// as `String`s.
20+
/// The preferred type order is provided by a generic `LosslessDecodingStrategy` that provides an ordered list of `losslessDecodableTypes`.
1221
@propertyWrapper
13-
public struct LosslessValue<T: LosslessStringCodable>: Codable {
22+
public struct LosslessValueCodable<Strategy: LosslessDecodingStrategy>: Codable {
1423
private let type: LosslessStringCodable.Type
15-
16-
public var wrappedValue: T
1724

18-
public init(wrappedValue: T) {
25+
public var wrappedValue: Strategy.Value
26+
27+
public init(wrappedValue: Strategy.Value) {
1928
self.wrappedValue = wrappedValue
20-
self.type = T.self
29+
self.type = Strategy.Value.self
2130
}
22-
31+
2332
public init(from decoder: Decoder) throws {
2433
do {
25-
self.wrappedValue = try T.init(from: decoder)
26-
self.type = T.self
27-
34+
self.wrappedValue = try Strategy.Value.init(from: decoder)
35+
self.type = Strategy.Value.self
2836
} catch let error {
29-
func decode<T: LosslessStringCodable>(_: T.Type) -> (Decoder) -> LosslessStringCodable? {
30-
return { try? T.init(from: $0) }
31-
}
32-
33-
func decodeBoolFromNSNumber() -> (Decoder) -> LosslessStringCodable? {
34-
return { (try? Int.init(from: $0)).flatMap { Bool(exactly: NSNumber(value: $0)) } }
35-
}
36-
37-
let types: [(Decoder) -> LosslessStringCodable?] = [
38-
decode(String.self),
39-
decodeBoolFromNSNumber(),
40-
decode(Bool.self),
41-
decode(Int.self),
42-
decode(Int8.self),
43-
decode(Int16.self),
44-
decode(Int64.self),
45-
decode(UInt.self),
46-
decode(UInt8.self),
47-
decode(UInt16.self),
48-
decode(UInt64.self),
49-
decode(Double.self),
50-
decode(Float.self),
51-
]
52-
5337
guard
54-
let rawValue = types.lazy.compactMap({ $0(decoder) }).first,
55-
let value = T.init("\(rawValue)")
56-
else { throw error }
57-
38+
let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first,
39+
let value = Strategy.Value.init("\(rawValue)")
40+
else { throw error }
41+
5842
self.wrappedValue = value
5943
self.type = Swift.type(of: rawValue)
6044
}
6145
}
62-
46+
6347
public func encode(to encoder: Encoder) throws {
6448
let string = String(describing: wrappedValue)
65-
49+
6650
guard let original = type.init(string) else {
6751
let description = "Unable to encode '\(wrappedValue)' back to source type '\(type)'"
6852
throw EncodingError.invalidValue(string, .init(codingPath: [], debugDescription: description))
6953
}
70-
54+
7155
try original.encode(to: encoder)
7256
}
7357
}
7458

75-
extension LosslessValue: Equatable where T: Equatable {
76-
public static func == (lhs: LosslessValue<T>, rhs: LosslessValue<T>) -> Bool {
59+
extension LosslessValueCodable: Equatable where Strategy.Value: Equatable {
60+
public static func == (lhs: LosslessValueCodable<Strategy>, rhs: LosslessValueCodable<Strategy>) -> Bool {
7761
return lhs.wrappedValue == rhs.wrappedValue
7862
}
7963
}
8064

81-
extension LosslessValue: Hashable where T: Hashable {
65+
extension LosslessValueCodable: Hashable where Strategy.Value: Hashable {
8266
public func hash(into hasher: inout Hasher) {
8367
hasher.combine(wrappedValue)
8468
}
8569
}
70+
71+
public struct LosslessDefaultStrategy<Value: LosslessStringCodable>: LosslessDecodingStrategy {
72+
public static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] {
73+
@inline(__always)
74+
func decode<T: LosslessStringCodable>(_: T.Type) -> (Decoder) -> LosslessStringCodable? {
75+
return { try? T.init(from: $0) }
76+
}
77+
78+
return [
79+
decode(String.self),
80+
decode(Bool.self),
81+
decode(Int.self),
82+
decode(Int8.self),
83+
decode(Int16.self),
84+
decode(Int64.self),
85+
decode(UInt.self),
86+
decode(UInt8.self),
87+
decode(UInt16.self),
88+
decode(UInt64.self),
89+
decode(Double.self),
90+
decode(Float.self),
91+
]
92+
}
93+
}
94+
95+
public struct LosslessBooleanStrategy<Value: LosslessStringCodable>: LosslessDecodingStrategy {
96+
public static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] {
97+
@inline(__always)
98+
func decode<T: LosslessStringCodable>(_: T.Type) -> (Decoder) -> LosslessStringCodable? {
99+
return { try? T.init(from: $0) }
100+
}
101+
102+
@inline(__always)
103+
func decodeBoolFromNSNumber() -> (Decoder) -> LosslessStringCodable? {
104+
return { (try? Int.init(from: $0)).flatMap { Bool(exactly: NSNumber(value: $0)) } }
105+
}
106+
107+
return [
108+
decode(String.self),
109+
decodeBoolFromNSNumber(),
110+
decode(Bool.self),
111+
decode(Int.self),
112+
decode(Int8.self),
113+
decode(Int16.self),
114+
decode(Int64.self),
115+
decode(UInt.self),
116+
decode(UInt8.self),
117+
decode(UInt16.self),
118+
decode(UInt64.self),
119+
decode(Double.self),
120+
decode(Float.self),
121+
]
122+
}
123+
}
124+
125+
/// Decodes Codable values into their respective preferred types.
126+
///
127+
/// `@LosslessValue` attempts to decode Codable types into their respective preferred types while preserving the data.
128+
///
129+
/// This is useful when data may return unpredictable values when a consumer is expecting a certain type. For instance,
130+
/// if an API sends SKUs as either an `Int` or `String`, then a `@LosslessValue` can ensure the types are always decoded
131+
/// as `String`s.
132+
///
133+
/// ```
134+
/// struct Product: Codable {
135+
/// @LosslessValue var sku: String
136+
/// @LosslessValue var id: String
137+
/// }
138+
///
139+
/// // json: { "sku": 87, "id": 123 }
140+
/// let value = try JSONDecoder().decode(Product.self, from: json)
141+
/// // value.sku == "87"
142+
/// // value.id == "123"
143+
/// ```
144+
public typealias LosslessValue<T> = LosslessValueCodable<LosslessDefaultStrategy<T>> where T: LosslessStringCodable
145+
146+
/// Decodes Codable values into their respective preferred types.
147+
///
148+
/// `@LosslessBoolValue` attempts to decode Codable types into their respective preferred types while preserving the data.
149+
///
150+
/// - Note:
151+
/// This uses a `LosslessBooleanStrategy` in order to prioritize boolean values, and as such, some integer values will be lossy.
152+
///
153+
/// For instance, if you decode `{ "some_type": 1 }` then `some_type` will be `true` and not `1`. If you do not want this
154+
/// behavior then use `@LosslessValue` or create a custom `LosslessDecodingStrategy`.
155+
///
156+
/// ```
157+
/// struct Example: Codable {
158+
/// @LosslessBoolValue var foo: Bool
159+
/// @LosslessValue var bar: Int
160+
/// }
161+
///
162+
/// // json: { "foo": 1, "bar": 2 }
163+
/// let value = try JSONDecoder().decode(Fixture.self, from: json)
164+
/// // value.foo == true
165+
/// // value.bar == 2
166+
/// ```
167+
public typealias LosslessBoolValue<T> = LosslessValueCodable<LosslessBooleanStrategy<T>> where T: LosslessStringCodable
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import XCTest
2+
import BetterCodable
3+
4+
struct MyLosslessStrategy<Value: LosslessStringCodable>: LosslessDecodingStrategy {
5+
static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] {
6+
[
7+
{ try? String(from: $0) },
8+
{ try? Bool(from: $0) },
9+
{ try? Int(from: $0) },
10+
{ _ in return 42 },
11+
]
12+
}
13+
}
14+
15+
typealias MyLosslessType<T> = LosslessValueCodable<MyLosslessStrategy<T>> where T: LosslessStringCodable
16+
17+
class LosslessCustomValueTests: XCTestCase {
18+
struct Fixture: Equatable, Codable {
19+
@MyLosslessType var int: Int
20+
@MyLosslessType var string: String
21+
@MyLosslessType var fortytwo: Int
22+
@MyLosslessType var bool: Bool
23+
}
24+
25+
func testDecodingCustomLosslessStrategyDecodesCorrectly() throws {
26+
let jsonData = #"{ "string": 7, "int": "1", "fortytwo": null, "bool": true }"#.data(using: .utf8)!
27+
let fixture = try JSONDecoder().decode(Fixture.self, from: jsonData)
28+
XCTAssertEqual(fixture.string, "7")
29+
XCTAssertEqual(fixture.int, 1)
30+
XCTAssertEqual(fixture.fortytwo, 42)
31+
XCTAssertEqual(fixture.bool, true)
32+
}
33+
34+
func testDecodingCustomLosslessStrategyWithBrokenFieldsThrowsError() throws {
35+
let jsonData = #"{ "string": 7, "int": "1", "fortytwo": null, "bool": 9 }"#.data(using: .utf8)!
36+
XCTAssertThrowsError(try JSONDecoder().decode(Fixture.self, from: jsonData))
37+
}
38+
}

Tests/BetterCodableTests/LosslessValueTests.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,22 @@ class LosslessValueTests: XCTestCase {
1717
XCTAssertEqual(fixture.int, 1)
1818
XCTAssertEqual(fixture.double, 7.1)
1919
}
20-
20+
2121
func testDecodingEncodedMisalignedTypesFromJSONDecodesCorrectTypes() throws {
2222
let jsonData = #"{ "bool": "true", "string": 42, "int": "7", "double": "7.1" }"#.data(using: .utf8)!
2323
var _fixture = try JSONDecoder().decode(Fixture.self, from: jsonData)
24-
24+
2525
_fixture.bool = false
2626
_fixture.double = 3.14
27-
27+
2828
let fixtureData = try JSONEncoder().encode(_fixture)
2929
let fixture = try JSONDecoder().decode(Fixture.self, from: fixtureData)
3030
XCTAssertEqual(fixture.bool, false)
3131
XCTAssertEqual(fixture.string, "42")
3232
XCTAssertEqual(fixture.int, 7)
3333
XCTAssertEqual(fixture.double, 3.14)
3434
}
35-
35+
3636
func testEncodingAndDecodedExpectedTypes() throws {
3737
let jsonData = #"{ "bool": true, "string": "42", "int": 7, "double": 7.1 }"#.data(using: .utf8)!
3838
let _fixture = try JSONDecoder().decode(Fixture.self, from: jsonData)
@@ -45,13 +45,33 @@ class LosslessValueTests: XCTestCase {
4545
}
4646

4747
func testDecodingBoolIntValueFromJSONDecodesCorrectly() throws {
48+
struct FixtureWithBooleanAsInteger: Equatable, Codable {
49+
@LosslessBoolValue var bool: Bool
50+
@LosslessValue var string: String
51+
@LosslessValue var int: Int
52+
@LosslessValue var double: Double
53+
}
54+
4855
let jsonData = #"{ "bool": 1, "string": "42", "int": 7, "double": 7.1 }"#.data(using: .utf8)!
49-
let _fixture = try JSONDecoder().decode(Fixture.self, from: jsonData)
56+
let _fixture = try JSONDecoder().decode(FixtureWithBooleanAsInteger.self, from: jsonData)
5057
let fixtureData = try JSONEncoder().encode(_fixture)
51-
let fixture = try JSONDecoder().decode(Fixture.self, from: fixtureData)
58+
let fixture = try JSONDecoder().decode(FixtureWithBooleanAsInteger.self, from: fixtureData)
5259
XCTAssertEqual(fixture.bool, true)
5360
XCTAssertEqual(fixture.string, "42")
5461
XCTAssertEqual(fixture.int, 7)
5562
XCTAssertEqual(fixture.double, 7.1)
5663
}
64+
65+
func testBoolAsIntegerShouldNotConflictWithDefaultStrategy() throws {
66+
struct Response: Codable {
67+
@LosslessValue var id: String
68+
@LosslessBoolValue var bool: Bool
69+
}
70+
71+
let json = #"{ "id": 1, "bool": 1 }"#.data(using: .utf8)!
72+
let result = try JSONDecoder().decode(Response.self, from: json)
73+
74+
XCTAssertEqual(result.id, "1")
75+
XCTAssertEqual(result.bool, true)
76+
}
5777
}

0 commit comments

Comments
 (0)