Skip to content

Commit

Permalink
IntDecodingStrategy.clamping
Browse files Browse the repository at this point in the history
  • Loading branch information
swhitty committed Jun 27, 2024
1 parent 44e0d68 commit 9c16e5c
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 21 deletions.
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

0 comments on commit 9c16e5c

Please sign in to comment.