Skip to content

Commit 0d24744

Browse files
committed
Adds LosslessValue strategy - wip
1 parent 9913396 commit 0d24744

3 files changed

Lines changed: 165 additions & 53 deletions

File tree

Sources/BetterCodable/LosslessValue.swift

Lines changed: 101 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,84 +2,138 @@ 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 produced value.
6+
///
7+
/// `LosslessDecodingStrategy` provides a generic strategy type that the `LosslessValueCodable` property wrapper can use to provide
8+
/// the ordered list of decodable types in order to maximize preservation and robustness for the otherwise lossy data.
9+
public protocol LosslessDecodingStrategy {
10+
associatedtype Value: LosslessStringCodable
11+
12+
static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] { get }
13+
}
14+
515
/// Decodes Codable values into their respective preferred types.
616
///
7-
/// `@LosslessValue` attempts to decode Codable types into their respective preferred types while preserving the data.
17+
/// `@LosslessValueCodable` attempts to decode Codable types into their preferred order while preserving the data in the most lossless format.
818
///
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.
19+
/// The preferred type order is provided by a generic `LosslessDecodingStrategy` that provides an ordered list of `losslessDecodableTypes`.
1220
@propertyWrapper
13-
public struct LosslessValue<T: LosslessStringCodable>: Codable {
21+
public struct LosslessValueCodable<Strategy: LosslessDecodingStrategy>: Codable {
1422
private let type: LosslessStringCodable.Type
15-
16-
public var wrappedValue: T
1723

18-
public init(wrappedValue: T) {
24+
public var wrappedValue: Strategy.Value
25+
26+
public init(wrappedValue: Strategy.Value) {
1927
self.wrappedValue = wrappedValue
20-
self.type = T.self
28+
self.type = Strategy.Value.self
2129
}
22-
30+
2331
public init(from decoder: Decoder) throws {
2432
do {
25-
self.wrappedValue = try T.init(from: decoder)
26-
self.type = T.self
27-
33+
self.wrappedValue = try Strategy.Value.init(from: decoder)
34+
self.type = Strategy.Value.self
2835
} 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-
5336
guard
54-
let rawValue = types.lazy.compactMap({ $0(decoder) }).first,
55-
let value = T.init("\(rawValue)")
56-
else { throw error }
57-
37+
let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first,
38+
let value = Strategy.Value.init("\(rawValue)")
39+
else { throw error }
40+
5841
self.wrappedValue = value
5942
self.type = Swift.type(of: rawValue)
6043
}
6144
}
62-
45+
6346
public func encode(to encoder: Encoder) throws {
6447
let string = String(describing: wrappedValue)
65-
48+
6649
guard let original = type.init(string) else {
6750
let description = "Unable to encode '\(wrappedValue)' back to source type '\(type)'"
6851
throw EncodingError.invalidValue(string, .init(codingPath: [], debugDescription: description))
6952
}
70-
53+
7154
try original.encode(to: encoder)
7255
}
7356
}
7457

75-
extension LosslessValue: Equatable where T: Equatable {
76-
public static func == (lhs: LosslessValue<T>, rhs: LosslessValue<T>) -> Bool {
58+
extension LosslessValueCodable: Equatable where Strategy.Value: Equatable {
59+
public static func == (lhs: LosslessValueCodable<Strategy>, rhs: LosslessValueCodable<Strategy>) -> Bool {
7760
return lhs.wrappedValue == rhs.wrappedValue
7861
}
7962
}
8063

81-
extension LosslessValue: Hashable where T: Hashable {
64+
extension LosslessValueCodable: Hashable where Strategy.Value: Hashable {
8265
public func hash(into hasher: inout Hasher) {
8366
hasher.combine(wrappedValue)
8467
}
8568
}
69+
70+
public struct LosslessDefaultStrategy<Value: LosslessStringCodable>: LosslessDecodingStrategy {
71+
public static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] {
72+
func decode<T: LosslessStringCodable>(_: T.Type) -> (Decoder) -> LosslessStringCodable? {
73+
return { try? T.init(from: $0) }
74+
}
75+
76+
return [
77+
decode(String.self),
78+
decode(Bool.self),
79+
decode(Int.self),
80+
decode(Int8.self),
81+
decode(Int16.self),
82+
decode(Int64.self),
83+
decode(UInt.self),
84+
decode(UInt8.self),
85+
decode(UInt16.self),
86+
decode(UInt64.self),
87+
decode(Double.self),
88+
decode(Float.self),
89+
]
90+
}
91+
}
92+
93+
public struct LosslessBooleanStrategy<Value: LosslessStringCodable>: LosslessDecodingStrategy {
94+
public static var losslessDecodableTypes: [(Decoder) -> LosslessStringCodable?] {
95+
func decode<T: LosslessStringCodable>(_: T.Type) -> (Decoder) -> LosslessStringCodable? {
96+
return { try? T.init(from: $0) }
97+
}
98+
99+
func decodeBoolFromNSNumber() -> (Decoder) -> LosslessStringCodable? {
100+
return { (try? Int.init(from: $0)).flatMap { Bool(exactly: NSNumber(value: $0)) } }
101+
}
102+
103+
return [
104+
decode(String.self),
105+
decodeBoolFromNSNumber(),
106+
decode(Bool.self),
107+
decode(Int.self),
108+
decode(Int8.self),
109+
decode(Int16.self),
110+
decode(Int64.self),
111+
decode(UInt.self),
112+
decode(UInt8.self),
113+
decode(UInt16.self),
114+
decode(UInt64.self),
115+
decode(Double.self),
116+
decode(Float.self),
117+
]
118+
}
119+
}
120+
121+
/// Decodes Codable values into their respective preferred types.
122+
///
123+
/// `@LosslessValue` attempts to decode Codable types into their respective preferred types while preserving the data.
124+
///
125+
/// This is useful when data may return unpredictable values when a consumer is expecting a certain type. For instance,
126+
/// if an API sends SKUs as either an `Int` or `String`, then a `@LosslessValue` can ensure the types are always decoded
127+
/// as `String`s.
128+
public typealias LosslessValue<T> = LosslessValueCodable<LosslessDefaultStrategy<T>> where T: LosslessStringCodable
129+
130+
/// Decodes Codable values into their respective preferred types.
131+
///
132+
/// `@LosslessBoolValue` attempts to decode Codable types into their respective preferred types while preserving the data.
133+
///
134+
/// - Note:
135+
///
136+
/// This differs from `@LosslessValue` in that it strongly prefers to keep the boolean value above all else, and some integer values will be lossy. For instance,
137+
/// if you decode `{ "some_type": 1 }` then `some_type` will be `true` and not `1`. If you do not want this behavior then stick with `@LosslessValue` or create
138+
/// your own custom `LosslessDecodingStrategy` type.
139+
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)