From 66e0ace17e37586d053181d3cc91f688fb42c05f Mon Sep 17 00:00:00 2001 From: Simon Whitty Date: Fri, 28 Jun 2024 08:20:44 +1000 Subject: [PATCH] IntDecodingStrategy.clamping --- README.md | 12 +++++- Sources/KeyValueDecoder.swift | 74 +++++++++++++++++++++++++------- Tests/KeyValueDecoderTests.swift | 73 ++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index eb8c954..8fa95dd 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,22 @@ Values with a fractional part can also be decoded to integers by rounding with a ```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): diff --git a/Sources/KeyValueDecoder.swift b/Sources/KeyValueDecoder.swift index 14988ef..dfc1ee9 100644 --- a/Sources/KeyValueDecoder.swift +++ b/Sources/KeyValueDecoder.swift @@ -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?) } } @@ -182,19 +186,19 @@ private extension KeyValueDecoder { func getBinaryInteger(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) } @@ -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() @@ -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 } diff --git a/Tests/KeyValueDecoderTests.swift b/Tests/KeyValueDecoderTests.swift index c115ce7..efe5b9d 100644 --- a/Tests/KeyValueDecoderTests.swift +++ b/Tests/KeyValueDecoderTests.swift @@ -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), @@ -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), @@ -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])