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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ let any = try encoder.encode([1, 2, Int?.none, 3])

## Int Decoding Strategy

The decoding of [BinaryInteger](https://developer.apple.com/documentation/swift/binaryinteger) types (`Int`, `UInt` etc) can be adjusted via `intDecodingStrategy`.
The decoding of [`BinaryInteger`](https://developer.apple.com/documentation/swift/binaryinteger) types (`Int`, `UInt` etc) can be adjusted via `intDecodingStrategy`.

The default strategy `IntDecodingStrategy.exact` ensures the source value is exactly represented by the decoded type allowing floating point values with no fractional part to be decoded:

Expand All @@ -113,16 +113,26 @@ let values = try KeyValueDecoder().decode([Int8].self, from: [10, 20.0, -30.0, I
_ = try KeyValueDecoder().decode(Int8.self, from: 1000])
```

Values with a fractional part can also be decoded to integers by rounding with any [FloatingPointRoundingRule](https://developer.apple.com/documentation/swift/floatingpointroundingrule):
Values with a fractional part can also be decoded to integers by rounding with any [`FloatingPointRoundingRule`](https://developer.apple.com/documentation/swift/floatingpointroundingrule):

```swift
let decoder = KeyValueDecoder()
decoder.intDecodingStrategy = .rounded(rule: .toNearestOrAwayFromZero)
decoder.intDecodingStrategy = .rounding(rule: .toNearestOrAwayFromZero)

// [10, -21, 50]
let values = try decoder.decode([Int].self, from: [10.1, -20.9, 50.00001]),
```

Values can also be clamped to the representable range:

```swift
let decoder = KeyValueDecoder()
decoder.intDecodingStrategy = .clamping(roundingRule: .toNearestOrAwayFromZero)

// [10, 21, 127, -128]
let values = try decoder.decode([Int8].self, from: [10, 20.5, 1000, -Double.infinity])
```

## UserDefaults
Encode and decode [`Codable`](https://developer.apple.com/documentation/swift/codable) types with [`UserDefaults`](https://developer.apple.com/documentation/foundation/userdefaults):

Expand Down
74 changes: 58 additions & 16 deletions Sources/KeyValueDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ public final class KeyValueDecoder {
case exact

/// Decodes all floating point numbers using the provided rounding rule.
case rounded(rule: FloatingPointRoundingRule)
case rounding(rule: FloatingPointRoundingRule)

/// Clamps all integers to their min / max.
/// Floating point conversions are also clamped, rounded when a rule is provided
case clamping(roundingRule: FloatingPointRoundingRule?)
}
}

Expand Down Expand Up @@ -182,19 +186,19 @@ private extension KeyValueDecoder {

func getBinaryInteger<T: BinaryInteger>(of type: T.Type = T.self) throws -> T {
if let binaryInt = value as? any BinaryInteger {
guard let val = T(exactly: binaryInt) else {
guard let val = T(from: binaryInt, using: strategy.integers) else {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "\(valueDescription) at \(codingPath.makeKeyPath()), cannot be exactly represented by \(type)")
throw DecodingError.typeMismatch(type, context)
}
return val
} else if let int64 = (value as? NSNumber)?.getInt64Value() {
guard let val = T(exactly: int64) else {
guard let val = T(from: int64, using: strategy.integers) else {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "\(valueDescription) at \(codingPath.makeKeyPath()), cannot be exactly represented by \(type)")
throw DecodingError.typeMismatch(type, context)
}
return val
} else if let double = getDoubleValue(from: value, using: strategy.integers) {
guard let val = T(exactly: double) else {
} else if let double = (value as? NSNumber)?.getDoubleValue() {
guard let val = T(from: double, using: strategy.integers) else {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "\(valueDescription) at \(codingPath.makeKeyPath()), cannot be exactly represented by \(type)")
throw DecodingError.typeMismatch(type, context)
}
Expand All @@ -209,17 +213,19 @@ private extension KeyValueDecoder {
}
}

func getDoubleValue(from value: Any, using strategy: IntDecodingStrategy) -> Double? {
guard let double = (value as? NSNumber)?.getDoubleValue() else {
return nil
}
switch strategy {
case .exact:
return double
case .rounded(rule: let rule):
return double.rounded(rule)
}
}
// func getDoubleValue(from value: Any, using strategy: IntDecodingStrategy) -> Double? {
// guard let double = (value as? NSNumber)?.getDoubleValue() else {
// return nil
// }
// switch strategy {
// case .exact:
// return double
// case .rounded(rule: let rule):
// return double.rounded(rule)
// case .clamping(rule: let rule):
// return double.rounded(rule)
// }
// }

func decode(_ type: Bool.Type) throws -> Bool {
try getValue()
Expand Down Expand Up @@ -640,6 +646,42 @@ private extension KeyValueDecoder {
}
}

extension BinaryInteger {

init?(from source: Double, using strategy: KeyValueDecoder.IntDecodingStrategy) {
switch strategy {
case .exact:
self.init(exactly: source)
case .rounding(rule: let rule):
self.init(exactly: source.rounded(rule))
case .clamping(roundingRule: let rule):
self.init(clamping: source, rule: rule)
}
}

init?(from source: some BinaryInteger, using strategy: KeyValueDecoder.IntDecodingStrategy) {
switch strategy {
case .exact, .rounding:
self.init(exactly: source)
case .clamping:
self.init(clamping: source)
}
}

private init?(clamping source: Double, rule: FloatingPointRoundingRule? = nil) {
let rounded = rule.map(source.rounded) ?? source
if let int = Int64(exactly: rounded) {
self.init(clamping: int)
} else if source > Double(Int64.max) {
self.init(clamping: Int64.max)
} else if source < Double(Int64.min) {
self.init(clamping: Int64.min)
} else {
return nil
}
}
}

extension NSNumber {
func getInt64Value() -> Int64? {
guard let numberID = getNumberTypeID() else { return nil }
Expand Down
73 changes: 71 additions & 2 deletions Tests/KeyValueDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ final class KeyValueDecoderTests: XCTestCase {

func testDecodesRounded_Ints() {
let decoder = KeyValueDecoder()
decoder.intDecodingStrategy = .rounded(rule: .toNearestOrAwayFromZero)
decoder.intDecodingStrategy = .rounding(rule: .toNearestOrAwayFromZero)

XCTAssertEqual(
try decoder.decode(Int16.self, from: 10.0),
Expand Down Expand Up @@ -247,7 +247,7 @@ final class KeyValueDecoderTests: XCTestCase {

func testDecodesRounded_UInts() {
let decoder = KeyValueDecoder()
decoder.intDecodingStrategy = .rounded(rule: .toNearestOrAwayFromZero)
decoder.intDecodingStrategy = .rounding(rule: .toNearestOrAwayFromZero)

XCTAssertEqual(
try decoder.decode(UInt16.self, from: 10.0),
Expand Down Expand Up @@ -902,6 +902,75 @@ final class KeyValueDecoderTests: XCTestCase {
}
}

func testInt_ClampsDoubles() {
XCTAssertEqual(
Int8(from: 1000.0, using: .clamping(roundingRule: nil)),
Int8.max
)
XCTAssertEqual(
Int8(from: -1000.0, using: .clamping(roundingRule: nil)),
Int8.min
)
XCTAssertEqual(
Int8(from: 100.0, using: .clamping(roundingRule: nil)),
100
)
XCTAssertEqual(
Int8(from: 100.5, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
101
)
XCTAssertEqual(
Int8(from: Double.infinity, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
Int8.max
)
XCTAssertEqual(
Int8(from: -Double.infinity, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
Int8.min
)
XCTAssertNil(
Int8(from: Double.nan, using: .clamping(roundingRule: nil))
)
}

func testUInt_ClampsDoubles() {
XCTAssertEqual(
UInt8(from: 1000.0, using: .clamping(roundingRule: nil)),
UInt8.max
)
XCTAssertEqual(
UInt8(from: -1000.0, using: .clamping(roundingRule: nil)),
UInt8.min
)
XCTAssertEqual(
UInt8(from: 100.0, using: .clamping(roundingRule: nil)),
100
)
XCTAssertEqual(
UInt8(from: 100.5, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
101
)
XCTAssertEqual(
UInt8(from: Double.infinity, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
UInt8.max
)
XCTAssertEqual(
UInt8(from: -Double.infinity, using: .clamping(roundingRule: .toNearestOrAwayFromZero)),
UInt8.min
)
XCTAssertNil(
UInt8(from: Double.nan, using: .clamping(roundingRule: nil))
)

// [10, , 20.5, 1000, -Double.infinity]
let decoder = KeyValueDecoder()
decoder.intDecodingStrategy = .clamping(roundingRule: .toNearestOrAwayFromZero)
XCTAssertEqual(
try decoder.decode([Int8].self, from: [10, 20.5, 1000, -Double.infinity]),
[10, 21, 127, -128]
)

}

#if !os(WASI)
func testPlistCompatibleDecoder() throws {
let plistAny = try PropertyListEncoder.encodeAny([1, 2, Int?.none, 4])
Expand Down