Skip to content

JSONSerialization: Use NSDecimalNumber for parsing json numbers. #1655

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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
15 changes: 13 additions & 2 deletions Foundation/JSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1909,7 +1909,14 @@ extension _JSONDecoder {
// * and the integral value was <= Float.greatestFiniteMagnitude, we are willing to lose precision past 2^24
// * If it was a Float, you will get back the precise value
// * If it was a Double or Decimal, you will get back the nearest approximation if it will fit
let double = number.doubleValue

let double: Double
if value is NSDecimalNumber {
// Prefer to parse the NSDecimalNumber as a Double so that the value roundtrips correctly
double = Double(number.description) ?? number.doubleValue
} else {
double = number.doubleValue
}
guard abs(double) <= Double(Float.greatestFiniteMagnitude) else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Parsed JSON number \(number) does not fit in \(type)."))
}
Expand Down Expand Up @@ -1952,7 +1959,11 @@ extension _JSONDecoder {
// We are always willing to return the number as a Double:
// * If the original value was integral, it is guaranteed to fit in a Double; we are willing to lose precision past 2^53 if you encoded a UInt64 but requested a Double
// * If it was a Float or Double, you will get back the precise value
// * If it was Decimal, you will get back the nearest approximation
// * If it was Decimal, try reparsing as a Double to get a round-tripable value
if value is NSDecimalNumber, let d = Double(number.description) {
return d
}

return number.doubleValue

/* FIXME: If swift-corelibs-foundation doesn't change to use NSNumber, this code path will need to be included and tested:
Expand Down
76 changes: 46 additions & 30 deletions Foundation/JSONSerialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -822,45 +822,61 @@ private struct JSONReader {
]

func parseNumber(_ input: Index, options opt: JSONSerialization.ReadingOptions) throws -> (Any, Index)? {
func parseTypedNumber(_ address: UnsafePointer<UInt8>, count: Int) -> (Any, IndexDistance)? {
let temp_buffer_size = 64
var temp_buffer = [Int8](repeating: 0, count: temp_buffer_size)
return temp_buffer.withUnsafeMutableBufferPointer { (buffer: inout UnsafeMutableBufferPointer<Int8>) -> (Any, IndexDistance)? in
memcpy(buffer.baseAddress!, address, min(count, temp_buffer_size - 1)) // ensure null termination

let startPointer = buffer.baseAddress!
let intEndPointer = UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>.allocate(capacity: 1)
defer { intEndPointer.deallocate() }
let doubleEndPointer = UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>.allocate(capacity: 1)
defer { doubleEndPointer.deallocate() }
let intResult = strtol(startPointer, intEndPointer, 10)
let intDistance = startPointer.distance(to: intEndPointer[0]!)
let doubleResult = strtod(startPointer, doubleEndPointer)
let doubleDistance = startPointer.distance(to: doubleEndPointer[0]!)

guard doubleDistance > 0 else { return nil }
if intDistance == doubleDistance {
return (NSNumber(value: intResult), intDistance)
}
return (NSNumber(value: doubleResult), doubleDistance)
let ZERO = UInt8(ascii: "0")
let ONE = UInt8(ascii: "1")
let NINE = UInt8(ascii: "9")
let MINUS = UInt8(ascii: "-")

func parseTypedNumber(_ string: String) throws -> (Any, IndexDistance)? {
let scan = Scanner(string: string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth testing the performance of creating a new Scanner for every single number we try to parse — I suspect that this is a potentially prohibitively expensive operation.

var decimal = Decimal()
guard scan.scanDecimal(&decimal) else {
return nil
}
return (NSDecimalNumber(decimal: decimal), scan.scanLocation)
}


// Validate the first few characters look like a JSON encoded number:
// Optional '-' sign at start only 1 leading zero if followed by a decimal point.
var index = input
func nextDigit() -> UInt8? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this more clearly be called nextASCII()/nextCharacter()/similar?

guard let (ascii, nextIndex) = source.takeASCII(index) else { return nil }
index = nextIndex
return ascii
}

guard var digit = nextDigit() else { return nil }
guard digit == MINUS || (digit >= ZERO && digit <= NINE) else { return nil }
if digit == MINUS {
guard let d = nextDigit() else { return nil }
digit = d
}

if digit == ZERO {
if let digit2 = nextDigit(), digit2 >= ZERO && digit2 <= NINE {
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
userInfo: ["NSDebugDescription" : "Leading zeros not allowed character \(input)." ])
}
} else if digit < ONE || digit > NINE {
throw NSError(domain: NSCocoaErrorDomain, code: CocoaError.propertyListReadCorrupt.rawValue,
userInfo: ["NSDebugDescription" : "Numbers must start with a 1-9 at \(input)." ])
}

if source.encoding == .utf8 {
return parseTypedNumber(source.buffer.baseAddress!.advanced(by: input), count: source.buffer.count - input).map { return ($0.0, input + $0.1) }
let count = source.buffer.count - input
let ptr = UnsafeMutableRawPointer(mutating: source.buffer.baseAddress!.advanced(by: input))
guard let string = String(bytesNoCopy: ptr, length: count,
encoding: .utf8, freeWhenDone: false) else { return nil }
return try parseTypedNumber(string).map { return ($0.0, input + $0.1) }
}
else {
var numberCharacters = [UInt8]()
var string = ""
var index = input
while let (ascii, nextIndex) = source.takeASCII(index), JSONReader.numberCodePoints.contains(ascii) {
numberCharacters.append(ascii)
string.append(String(UnicodeScalar(ascii)))
index = nextIndex
}
numberCharacters.append(0)

return numberCharacters.withUnsafeBufferPointer {
parseTypedNumber($0.baseAddress!, count: $0.count)
}.map { return ($0.0, index) }
return try parseTypedNumber(string).map { return ($0.0, index) }
}
}

Expand Down
99 changes: 99 additions & 0 deletions TestFoundation/TestJSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,104 @@ class TestJSONEncoder : XCTestCase {
}
}

func test_numericLimits() {
struct DataStruct: Codable {
let int8Value: Int8?
let uint8Value: UInt8?
let int16Value: Int16?
let uint16Value: UInt16?
let int32Value: Int32?
let uint32Value: UInt32?
let int64Value: Int64?
let intValue: Int?
let uintValue: UInt?
let uint64Value: UInt64?
let floatValue: Float?
let doubleValue: Double?
}

func decode(_ type: String, _ value: String) throws {
var key = type.lowercased()
key.append("Value")
_ = try JSONDecoder().decode(DataStruct.self, from: "{ \"\(key)\": \(value) }".data(using: .utf8)!)
}

func testGoodValue(_ type: String, _ value: String) {
do {
try decode(type, value)
} catch {
XCTFail("Unexpected error: \(error) for parsing \(value) to \(type)")
}
}

func testErrorThrown(_ type: String, _ value: String, errorMessage: String) {
do {
try decode(type, value)
XCTFail("Decode of \(value) to \(type) should not succeed")
} catch DecodingError.dataCorrupted(let context) {
XCTAssertEqual(context.debugDescription, errorMessage)
} catch {
XCTAssertEqual(String(describing: error), errorMessage)
}
}


var goodValues = [
("Int8", "0"), ("Int8", "1"), ("Int8", "-1"), ("Int8", "-128"), ("Int8", "127"),
("UInt8", "0"), ("UInt8", "1"), ("UInt8", "255"), ("UInt8", "-0"),

("Int16", "0"), ("Int16", "1"), ("Int16", "-1"), ("Int16", "-32768"), ("Int16", "32767"),
("UInt16", "0"), ("UInt16", "1"), ("UInt16", "65535"), ("UInt16", "34.0"),

("Int32", "0"), ("Int32", "1"), ("Int32", "-1"), ("Int32", "-2147483648"), ("Int32", "2147483647"),
("UInt32", "0"), ("UInt32", "1"), ("UInt32", "4294967295"),

("Int64", "0"), ("Int64", "1"), ("Int64", "-1"), ("Int64", "-9223372036854775808"), ("Int64", "9223372036854775807"),
("UInt64", "0"), ("UInt64", "1"), ("UInt64", "18446744073709551615"),
]

if Int.max == Int64.max {
goodValues += [
("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-9223372036854775808"), ("Int", "9223372036854775807"),
("UInt", "0"), ("UInt", "1"), ("UInt", "18446744073709551615"),
]
} else {
goodValues += [
("Int", "0"), ("Int", "1"), ("Int", "-1"), ("Int", "-2147483648"), ("Int", "2147483647"),
("UInt", "0"), ("UInt", "1"), ("UInt", "4294967295"),
]
}

let badValues = [
("Int8", "-129"), ("Int8", "128"), ("Int8", "1.2"),
("UInt8", "-1"), ("UInt8", "256"),

("Int16", "-32769"), ("Int16", "32768"),
("UInt16", "-1"), ("UInt16", "65536"),

("Int32", "-2147483649"), ("Int32", "2147483648"),
("UInt32", "-1"), ("UInt32", "4294967296"),

("Int64", "-9223372036854775809"), ("Int64", "9223372036854775808"), ("Int64", "-100000000000000000000"),
("UInt64", "-1"), ("UInt64", "18446744073709551616"), ("Int64", "10000000000000000000000000000000000000"),
]

for value in goodValues {
testGoodValue(value.0, value.1)
}

for (type, value) in badValues {
testErrorThrown(type, value, errorMessage: "Parsed JSON number <\(value)> does not fit in \(type).")
}

// Leading zeros are invalid
testErrorThrown("Int8", "0000000000000000000000000000001", errorMessage: "The operation could not be completed")
testErrorThrown("Double", "-.1", errorMessage: "The operation could not be completed")
testErrorThrown("Int32", "+1", errorMessage: "The operation could not be completed")
testErrorThrown("Int", ".012", errorMessage: "The operation could not be completed")
}


// MARK: - Helper Functions
private var _jsonEmptyDictionary: Data {
return "{}".data(using: .utf8)!
Expand Down Expand Up @@ -1089,6 +1187,7 @@ extension TestJSONEncoder {
("test_codingOfDouble", test_codingOfDouble),
("test_codingOfString", test_codingOfString),
("test_codingOfURL", test_codingOfURL),
("test_numericLimits", test_numericLimits),
]
}
}