diff --git a/README.md b/README.md index 8e0d821..dc54710 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,49 @@ if let name = value["users"]?[0]?["name"]?.string { } ``` +### Number Precision + +By default, `YYJSONDecoder` decodes numbers losslessly. +Each number is read as its original text and parsed directly into the +requested Swift type, so values round-trip exactly — +including fractional `Decimal` values like `0.1` and integers larger than `UInt64`. +This matches the precision contract of Foundation's `JSONDecoder`. + +```swift +struct Account: Codable { + let balance: Decimal +} + +let data = Data(#"{"balance": 9999999999999999.99}"#.utf8) +let account = try YYJSONDecoder().decode(Account.self, from: data) +print(account.balance) // 9999999999999999.99 (no precision loss) +``` + +If you don't need exact decimals and want maximum throughput on number-heavy payloads, +opt into the faster (but lossy) strategy, +which routes every number through `Double`: + +```swift +var decoder = YYJSONDecoder() +decoder.numberDecodingStrategy = .fast +``` + +`YYJSONEncoder` writes `Decimal` values from their exact text +rather than going through `Double`, +so encoded decimals preserve full precision regardless of the host locale. + +For DOM-style access, parse with `.numberAsRaw` +and read the `decimal` property to recover the exact value: + +```swift +let value = try YYJSONValue(string: #"{"price": 19.99}"#, options: .numberAsRaw) +print(value["price"]?.decimal) // Optional(19.99) +``` + +The `number` property returns a `Double` for convenience. +`YYJSONSerialization` likewise preserves precision, bridging fractional +and oversized integer values to `NSDecimalNumber`. + ### In-Place Parsing For maximum performance with large JSON files, @@ -502,8 +545,9 @@ However, there are some differences: `keyEncodingStrategy` or `nonConformingFloatEncodingStrategy` - **Output formatting**: Uses `writeOptions` instead of `outputFormatting` -- **Number precision**: yyjson parses numbers as 64-bit integers or doubles; - extremely large integers may lose precision +- **Number precision**: `YYJSONDecoder` decodes numbers losslessly by default, matching `JSONDecoder`. + Opt into the faster, `Double`-based strategy with `numberDecodingStrategy = .fast` + (see [Number Precision](#number-precision)). ## Thread Safety @@ -511,8 +555,9 @@ However, there are some differences: multiple threads, as long as each `encode`/`decode` call is not shared concurrently. - `YYJSONValue`, `YYJSONObject`, and `YYJSONArray` are safe to share across threads for read-only access; they wrap an immutable yyjson document. -- The `number` property on `YYJSONValue` returns a `Double`. For exact representation - of very large numbers, parse using `.bigNumberAsRaw` and read them as strings. +- The `number` property on `YYJSONValue` returns a `Double`. + For exact representation, parse using `.numberAsRaw` (or `.bigNumberAsRaw`) + and read the `decimal` property. ## License diff --git a/Sources/YYJSON/Decoder.swift b/Sources/YYJSON/Decoder.swift index ff7362b..43bb42e 100644 --- a/Sources/YYJSON/Decoder.swift +++ b/Sources/YYJSON/Decoder.swift @@ -1,6 +1,14 @@ import Cyyjson import Foundation +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + #if !YYJSON_DISABLE_READER // MARK: - Helper Functions @@ -22,6 +30,198 @@ import Foundation } } + /// Returns the textual representation of a numeric JSON value. + /// + /// The decoder enables `YYJSON_READ_NUMBER_AS_RAW`, + /// so numbers normally arrive as raw values whose original input text is returned verbatim. + /// For values stored as a parsed number + /// (when callers bypass the decoder and construct a value manually), + /// the typed getter is formatted via Swift's locale-independent `String` initializers, + /// which produce shortest round-trippable representations. + /// Returns `nil` if the value is neither numeric nor raw. + @inline(__always) + func yyNumberText(_ val: UnsafeMutablePointer) -> String? { + if yyjson_is_raw(val) { return yyRawText(val) } + if yyjson_is_sint(val) { return String(yyjson_get_sint(val)) } + if yyjson_is_uint(val) { return String(yyjson_get_uint(val)) } + if yyjson_is_real(val) { return String(yyjson_get_real(val)) } + return nil + } + + /// Returns true if a JSON value carries numeric content, + /// either as a parsed number or as raw text preserved via `YYJSON_READ_NUMBER_AS_RAW`. + @inline(__always) + func yyIsNumeric(_ val: UnsafeMutablePointer) -> Bool { + return yyjson_is_num(val) || yyjson_is_raw(val) + } + + /// Returns a short human-readable name for `val`'s JSON type, + /// used in `YYJSONError.typeMismatch` diagnostics. + @inline(__always) + func yyTypeString(_ val: UnsafeMutablePointer) -> String { + switch yyjson_get_type(val) { + case YYJSON_TYPE_NULL: return "null" + case YYJSON_TYPE_BOOL: return "bool" + case YYJSON_TYPE_NUM, YYJSON_TYPE_RAW: return "number" + case YYJSON_TYPE_STR: return "string" + case YYJSON_TYPE_ARR: return "array" + case YYJSON_TYPE_OBJ: return "object" + default: return "unknown" + } + } + + /// Decodes a JSON numeric value into a `Decimal`, + /// preserving the original input text when the document was read with + /// `YYJSON_READ_NUMBER_AS_RAW`. + /// + /// Throws `YYJSONError.missingValue` when `value` is `nil`, + /// `YYJSONError.typeMismatch` when the value is not numeric, + /// and `YYJSONError.invalidData` when the numeric text falls outside + /// `Decimal`'s representable range or parses to `Decimal.nan`. + /// The encoder refuses to emit NaN, + /// so accepting it on decode would break round-trips. + func yyDecodeDecimal(from value: UnsafeMutablePointer?, path: String) throws -> Decimal { + guard let value = value else { + throw YYJSONError.missingValue(path: path) + } + guard yyIsNumeric(value) else { + throw YYJSONError.typeMismatch( + expected: "number", + actual: yyTypeString(value), + path: path + ) + } + guard let string = yyNumberText(value), + let decimal = Decimal(string: string, locale: yyPOSIXLocale), + !decimal.isNaN + else { + throw YYJSONError.invalidData( + "Could not parse number as Decimal", + path: path + ) + } + return decimal + } + + /// Parses a numeric JSON value as `Double` without allocating an intermediate `String`. + /// + /// For raw values (the common path under `YYJSON_READ_NUMBER_AS_RAW`), + /// `strtoll`/`strtoull` are tried first with base 0 + /// so JSON5 hex literals (`0xFF`) preserved as raw text are admitted as `Double`-convertible integers; + /// `strtod` handles the remaining fractional, exponential, and non-finite (`Infinity`/`NaN`) forms. + /// For values stored natively, + /// `yyjson_get_num` returns the parsed result. + @inline(__always) + func yyParseDouble(_ val: UnsafeMutablePointer) -> Double? { + if yyjson_is_raw(val) { + guard let ptr = unsafe_yyjson_get_raw(val) else { return nil } + let len = unsafe_yyjson_get_len(val) + var iend: UnsafeMutablePointer? + errno = 0 + let i = strtoll(ptr, &iend, 0) + if errno == 0, let e = iend, ptr.distance(to: UnsafePointer(e)) == len { + return Double(i) + } + if len == 0 || ptr.pointee != 0x2D /* '-' */ { + errno = 0 + var uend: UnsafeMutablePointer? + let u = strtoull(ptr, &uend, 0) + if errno == 0, let e = uend, ptr.distance(to: UnsafePointer(e)) == len { + return Double(u) + } + } + var end: UnsafeMutablePointer? + let d = strtod(ptr, &end) + guard let e = end, ptr.distance(to: UnsafePointer(e)) == len else { return nil } + return d + } + if yyjson_is_num(val) { + return yyjson_get_num(val) + } + return nil + } + + /// Parses a numeric JSON value as a fixed-width signed integer. + /// + /// Tries `strtoll` first for plain integer text + /// (auto-detecting `0x` hex literals admitted by JSON5's extended-number mode); + /// falls back to a `Double` conversion for fractional or exponential forms, + /// and for integers that overflow `Int64`. + /// Range checking uses `T(exactly:)` against the truncated `Double` + /// to avoid the rounding pitfalls of comparing against `Double(T.min)`/`Double(T.max)`, + /// which are not exactly representable for 64-bit integer bounds. + @inline(__always) + func yyParseSignedInt( + _ val: UnsafeMutablePointer + ) -> T? { + if yyjson_is_raw(val) { + guard let ptr = unsafe_yyjson_get_raw(val) else { return nil } + let len = unsafe_yyjson_get_len(val) + var iend: UnsafeMutablePointer? + errno = 0 + let i = strtoll(ptr, &iend, 0) + if errno == 0, let e = iend, ptr.distance(to: UnsafePointer(e)) == len { + return T(exactly: i) + } + var dend: UnsafeMutablePointer? + let d = strtod(ptr, &dend) + guard let de = dend, ptr.distance(to: UnsafePointer(de)) == len, + d.isFinite + else { return nil } + return T(exactly: d.rounded(.towardZero)) + } + if yyjson_is_num(val) { + if yyjson_is_int(val) { return T(exactly: yyjson_get_sint(val)) } + let d = yyjson_get_num(val) + guard d.isFinite else { return nil } + return T(exactly: d.rounded(.towardZero)) + } + return nil + } + + /// Parses a numeric JSON value as a fixed-width unsigned integer. + /// + /// Tries `strtoull` first for plain integer text + /// (auto-detecting `0x` hex literals admitted by JSON5's extended-number mode); + /// falls back to a `Double` conversion for fractional or exponential forms, + /// and for integers that overflow `UInt64`. + /// Range checking uses `T(exactly:)` against the truncated `Double` + /// to avoid the rounding pitfalls of comparing against `Double(T.max)` for 64-bit unsigned bounds. + @inline(__always) + func yyParseUnsignedInt( + _ val: UnsafeMutablePointer + ) -> T? { + if yyjson_is_raw(val) { + guard let ptr = unsafe_yyjson_get_raw(val) else { return nil } + let len = unsafe_yyjson_get_len(val) + // Reject explicit negative sign before strtoull silently wraps it. + if len > 0, ptr.pointee == 0x2D /* '-' */ { return nil } + var iend: UnsafeMutablePointer? + errno = 0 + let i = strtoull(ptr, &iend, 0) + if errno == 0, let e = iend, ptr.distance(to: UnsafePointer(e)) == len { + return T(exactly: i) + } + var dend: UnsafeMutablePointer? + let d = strtod(ptr, &dend) + guard let de = dend, ptr.distance(to: UnsafePointer(de)) == len, + d.isFinite, d >= 0 + else { return nil } + return T(exactly: d.rounded(.towardZero)) + } + if yyjson_is_num(val) { + if yyjson_is_int(val) { + let s = yyjson_get_sint(val) + if s < 0 { return nil } + return T(exactly: UInt64(bitPattern: s)) + } + let d = yyjson_get_num(val) + guard d.isFinite, d >= 0 else { return nil } + return T(exactly: d.rounded(.towardZero)) + } + return nil + } + /// A decoder that decodes JSON data into Swift types using the yyjson library. public struct YYJSONDecoder { /// Options for reading JSON. @@ -39,6 +239,9 @@ import Foundation /// The strategy used by a decoder when it encounters exceptional floating-point values. public var nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy = .throw + /// The strategy used by a decoder when reading JSON numbers. + public var numberDecodingStrategy: NumberDecodingStrategy = .lossless + #if !YYJSON_DISABLE_NON_STANDARD /// Specifies that decoding supports the JSON5 syntax. @@ -62,6 +265,7 @@ import Foundation self.dateDecodingStrategy = .deferredToDate self.dataDecodingStrategy = .base64 self.nonConformingFloatDecodingStrategy = .throw + self.numberDecodingStrategy = .lossless #if !YYJSON_DISABLE_NON_STANDARD self.allowsJSON5 = false #endif @@ -79,6 +283,14 @@ import Foundation #if !YYJSON_DISABLE_NON_STANDARD options.formUnion(allowsJSON5.readOptions) #endif + // The `.lossless` strategy preserves the original text of every JSON number + // so that high-precision types like `Decimal` can be decoded exactly. + // `.fast` lets yyjson parse numbers as `Int64`/`UInt64`/`Double` natively, + // restoring the library's native throughput + // at the cost of fractional `Decimal` precision and very large integer range. + if numberDecodingStrategy == .lossless { + options.insert(.numberAsRaw) + } let document = try YYDocument(data: data, options: options) guard let root = document.root else { @@ -95,6 +307,14 @@ import Foundation nonConformingFloatDecodingStrategy: nonConformingFloatDecodingStrategy ) + // Decimal's default Decodable implementation expects a keyed container, + // so we intercept top-level Decimal decoding to read a JSON number directly. + if type == Decimal.self { + let container = try decoder.singleValueContainer() + let decimal = try container.decode(Decimal.self) + return decimal as! T + } + return try T(from: decoder) } } @@ -239,6 +459,33 @@ import Foundation case convertFromString(positiveInfinity: String, negativeInfinity: String, nan: String) } + /// The strategies for decoding JSON numbers, + /// trading exact precision for throughput. + public enum NumberDecodingStrategy: Sendable { + /// Reads every JSON number's original input text + /// and parses it directly into the requested Swift type. + /// + /// Required for lossless `Decimal` decoding + /// (including fractional values like `0.1` and integers beyond `UInt64`). + /// This is the default and matches Foundation `JSONDecoder`'s precision contract. + case lossless + + /// Reads numbers using yyjson's native `Int64`/`UInt64`/`Double` parsers. + /// + /// Recovers yyjson's native throughput for number-heavy payloads, + /// at the cost of precision. + /// Every numeric value passes through `Double` before reaching Swift, so: + /// + /// - Fractional values decoded as `Decimal` may not round-trip exactly + /// (e.g. `0.1` decodes as `Decimal(0.1000000000000000055...)`). + /// - Integer literals outside the `Int64`/`UInt64` range are parsed as `Double` + /// and decode into `Decimal` with `Double` precision rather than preserving every digit. + /// + /// Choose this when you control the data shape + /// and know it doesn't contain high-precision decimals or arbitrary-precision integers. + case fast + } + // MARK: - Internal Decoder Implementation /// Internal decoder implementing the Decoder protocol. @@ -339,7 +586,7 @@ import Foundation return "null" case YYJSON_TYPE_BOOL: return "bool" - case YYJSON_TYPE_NUM: + case YYJSON_TYPE_NUM, YYJSON_TYPE_RAW: return "number" case YYJSON_TYPE_STR: return "string" @@ -458,8 +705,7 @@ import Foundation if yyjson_is_bool(val) { return yyjson_get_bool(val) } - if yyjson_is_num(val) { - let num = yyjson_get_num(val) + if let num = yyParseDouble(val) { return num != 0.0 } if yyjson_is_str(val) { @@ -484,8 +730,8 @@ import Foundation if yyjson_is_str(val) { return yyToString(val) } - if yyjson_is_num(val) { - return String(yyjson_get_num(val)) + if let text = yyNumberText(val) { + return text } if yyjson_is_bool(val) { return yyjson_get_bool(val) ? "true" : "false" @@ -500,9 +746,7 @@ import Foundation func decode(_ type: Double.Type, forKey key: Key) throws -> Double { try decodeValue(forKey: key) { val in - if yyjson_is_num(val) { - let num = yyjson_get_num(val) - + if let num = yyParseDouble(val) { if !num.isFinite { switch nonConformingFloatDecodingStrategy { case .throw: @@ -514,7 +758,6 @@ import Foundation return num } } - return num } if yyjson_is_str(val) { @@ -551,17 +794,11 @@ import Foundation func decode(_ type: Int.Type, forKey key: Key) throws -> Int { try decodeValue(forKey: key) { val in - if yyjson_is_num(val) { - if yyjson_is_int(val) { - let sint = yyjson_get_sint(val) - return Int(sint) - } - return Int(yyjson_get_num(val)) + if let num: Int = yyParseSignedInt(val) { + return num } - if yyjson_is_str(val) { - if let num = Int(yyToString(val)) { - return num - } + if yyjson_is_str(val), let num = Int(yyToString(val)) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -585,16 +822,11 @@ import Foundation func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { try decodeValue(forKey: key) { val in - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return yyjson_get_sint(val) - } - return Int64(yyjson_get_num(val)) + if let num: Int64 = yyParseSignedInt(val) { + return num } - if yyjson_is_str(val) { - if let num = Int64(yyToString(val)) { - return num - } + if yyjson_is_str(val), let num = Int64(yyToString(val)) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -606,17 +838,11 @@ import Foundation func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { try decodeValue(forKey: key) { val in - if yyjson_is_num(val) { - if yyjson_is_int(val) { - let uint = yyjson_get_uint(val) - return UInt(uint) - } - return UInt(yyjson_get_num(val)) + if let num: UInt = yyParseUnsignedInt(val) { + return num } - if yyjson_is_str(val) { - if let num = UInt(yyToString(val)) { - return num - } + if yyjson_is_str(val), let num = UInt(yyToString(val)) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -640,13 +866,11 @@ import Foundation func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { try decodeValue(forKey: key) { val in - if yyjson_is_num(val) { - return yyjson_get_uint(val) + if let num: UInt64 = yyParseUnsignedInt(val) { + return num } - if yyjson_is_str(val) { - if let num = UInt64(yyToString(val)) { - return num - } + if yyjson_is_str(val), let num = UInt64(yyToString(val)) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -671,6 +895,11 @@ import Foundation return data as! T } + if type == Decimal.self { + let decimal = try yyDecodeDecimal(from: val, path: pathString(for: key)) + return decimal as! T + } + let decoder = _YYDecoder( value: val, codingPath: codingPath + [key], @@ -823,10 +1052,7 @@ import Foundation from value: UnsafeMutablePointer, path: String ) throws -> T where T: BinaryFloatingPoint { - if yyjson_is_num(value) { - let num = yyjson_get_num(value) - - // Check for non-conforming floats + if let num = yyParseDouble(value) { if !num.isFinite { switch nonConformingFloatDecodingStrategy { case .throw: @@ -853,7 +1079,6 @@ import Foundation } } } - return T(num) } @@ -939,7 +1164,7 @@ import Foundation return "null" case YYJSON_TYPE_BOOL: return "bool" - case YYJSON_TYPE_NUM: + case YYJSON_TYPE_NUM, YYJSON_TYPE_RAW: return "number" case YYJSON_TYPE_STR: return "string" @@ -1038,9 +1263,7 @@ import Foundation throw YYJSONError.missingValue(path: pathString) } currentIndex += 1 - if yyjson_is_num(val) { - let num = yyjson_get_num(val) - + if let num = yyParseDouble(val) { if !num.isFinite { switch nonConformingFloatDecodingStrategy { case .throw: @@ -1052,7 +1275,6 @@ import Foundation return num } } - return num } if yyjson_is_str(val) { @@ -1091,11 +1313,8 @@ import Foundation throw YYJSONError.missingValue(path: pathString) } currentIndex += 1 - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return Int(yyjson_get_sint(val)) - } - return Int(yyjson_get_num(val)) + if let num: Int = yyParseSignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -1121,11 +1340,8 @@ import Foundation throw YYJSONError.missingValue(path: pathString) } currentIndex += 1 - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return yyjson_get_sint(val) - } - return Int64(yyjson_get_num(val)) + if let num: Int64 = yyParseSignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -1139,11 +1355,8 @@ import Foundation throw YYJSONError.missingValue(path: pathString) } currentIndex += 1 - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return UInt(yyjson_get_uint(val)) - } - return UInt(yyjson_get_num(val)) + if let num: UInt = yyParseUnsignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -1169,8 +1382,8 @@ import Foundation throw YYJSONError.missingValue(path: pathString) } currentIndex += 1 - if yyjson_is_num(val) { - return yyjson_get_uint(val) + if let num: UInt64 = yyParseUnsignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -1196,6 +1409,11 @@ import Foundation return data as! T } + if type == Decimal.self { + let decimal = try yyDecodeDecimal(from: val, path: pathString) + return decimal as! T + } + let decoder = _YYDecoder( value: val, codingPath: currentCodingPath, @@ -1395,7 +1613,7 @@ import Foundation return "null" case YYJSON_TYPE_BOOL: return "bool" - case YYJSON_TYPE_NUM: + case YYJSON_TYPE_NUM, YYJSON_TYPE_RAW: return "number" case YYJSON_TYPE_STR: return "string" @@ -1471,9 +1689,7 @@ import Foundation guard let val = value else { throw YYJSONError.missingValue(path: pathString) } - if yyjson_is_num(val) { - let num = yyjson_get_num(val) - + if let num = yyParseDouble(val) { if !num.isFinite { switch nonConformingFloatDecodingStrategy { case .throw: @@ -1485,7 +1701,6 @@ import Foundation return num } } - return num } if yyjson_is_str(val) { @@ -1523,11 +1738,8 @@ import Foundation guard let val = value else { throw YYJSONError.missingValue(path: pathString) } - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return Int(yyjson_get_sint(val)) - } - return Int(yyjson_get_num(val)) + if let num: Int = yyParseSignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -1552,11 +1764,8 @@ import Foundation guard let val = value else { throw YYJSONError.missingValue(path: pathString) } - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return yyjson_get_sint(val) - } - return Int64(yyjson_get_num(val)) + if let num: Int64 = yyParseSignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "integer", @@ -1569,11 +1778,8 @@ import Foundation guard let val = value else { throw YYJSONError.missingValue(path: pathString) } - if yyjson_is_num(val) { - if yyjson_is_int(val) { - return UInt(yyjson_get_uint(val)) - } - return UInt(yyjson_get_num(val)) + if let num: UInt = yyParseUnsignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -1598,8 +1804,8 @@ import Foundation guard let val = value else { throw YYJSONError.missingValue(path: pathString) } - if yyjson_is_num(val) { - return yyjson_get_uint(val) + if let num: UInt64 = yyParseUnsignedInt(val) { + return num } throw YYJSONError.typeMismatch( expected: "unsigned integer", @@ -1624,6 +1830,11 @@ import Foundation return data as! T } + if type == Decimal.self { + let decimal = try yyDecodeDecimal(from: val, path: pathString) + return decimal as! T + } + let decoder = _YYDecoder( value: val, codingPath: codingPath, @@ -1771,7 +1982,7 @@ import Foundation return "null" case YYJSON_TYPE_BOOL: return "bool" - case YYJSON_TYPE_NUM: + case YYJSON_TYPE_NUM, YYJSON_TYPE_RAW: return "number" case YYJSON_TYPE_STR: return "string" diff --git a/Sources/YYJSON/Encoder.swift b/Sources/YYJSON/Encoder.swift index 8640947..bc0216b 100644 --- a/Sources/YYJSON/Encoder.swift +++ b/Sources/YYJSON/Encoder.swift @@ -62,8 +62,13 @@ import Foundation writeOptions: writeOptions ) - try value.encode(to: encoder) - + // Decimal's default Encodable implementation produces a keyed container, + // so we intercept top-level Decimal encoding to emit a JSON number directly. + if let decimal = value as? Decimal { + encoder.value = try encoder.decimalValue(decimal, codingPath: []) + } else { + try value.encode(to: encoder) + } guard let root = encoder.value else { throw YYJSONError.invalidData("Failed to encode root value") } @@ -201,6 +206,28 @@ import Foundation #endif return yyjson_mut_real(doc, value) } + + func decimalValue(_ value: Decimal, codingPath: [CodingKey]) throws + -> UnsafeMutablePointer + { + if value.isNaN { + throw YYJSONError.invalidData( + "Cannot encode Decimal NaN as a JSON number", + path: codingPath.map { $0.stringValue }.joined(separator: ".") + ) + } + // `Decimal.description` formats with the user's current locale, + // which can produce a `,` decimal separator and emit invalid JSON. + // Render through `NSDecimalNumber.description(withLocale:)` with POSIX + // so the output is always JSON-conformant. + var string = NSDecimalNumber(decimal: value).description(withLocale: yyPOSIXLocale) + return string.withUTF8 { buf in + guard let ptr = buf.baseAddress else { + return yyjson_mut_rawncpy(doc, "0", 1) + } + return yyjson_mut_rawncpy(doc, ptr, buf.count) + } + } } // MARK: - Encoding Containers @@ -339,6 +366,13 @@ import Foundation return } + if let decimal = value as? Decimal { + let encodedValue = try encoder.decimalValue(decimal, codingPath: codingPath + [key]) + let keyVal = yyFromString(key.stringValue, in: doc) + _ = yyjson_mut_obj_put(obj, keyVal, encodedValue) + return + } + let encoder = _YYEncoder( doc: doc, value: nil, @@ -708,6 +742,15 @@ import Foundation return } + if let decimal = value as? Decimal { + let encodedValue = try encoder.decimalValue( + decimal, + codingPath: codingPath + [AnyCodingKey(index: count)] + ) + _ = yyjson_mut_arr_append(arr, encodedValue) + return + } + let encoder = _YYEncoder( doc: doc, value: nil, @@ -1008,6 +1051,12 @@ import Foundation return } + if let decimal = value as? Decimal { + self.value = try encoder.decimalValue(decimal, codingPath: codingPath) + encoder.value = self.value + return + } + let nestedEncoder = _YYEncoder( doc: doc, value: nil, diff --git a/Sources/YYJSON/Helpers.swift b/Sources/YYJSON/Helpers.swift index f154bd0..dc1b2b9 100644 --- a/Sources/YYJSON/Helpers.swift +++ b/Sources/YYJSON/Helpers.swift @@ -1,4 +1,10 @@ import Cyyjson +import Foundation + +/// Locale used to parse and format JSON numbers as `Decimal`. +/// JSON numbers always use `.` as the decimal separator, +/// so we pin to POSIX to avoid mis-handling under locales that use `,`. +let yyPOSIXLocale = Locale(identifier: "en_US_POSIX") #if !YYJSON_DISABLE_WRITER diff --git a/Sources/YYJSON/Serialization.swift b/Sources/YYJSON/Serialization.swift index 1e73bc7..a07ed83 100644 --- a/Sources/YYJSON/Serialization.swift +++ b/Sources/YYJSON/Serialization.swift @@ -1,6 +1,47 @@ import Cyyjson import Foundation +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + +#if !YYJSON_DISABLE_READER + + /// Parses a JSON numeric literal as a fixed-width integer. + /// + /// Accepts plain decimal integers (including a leading sign) + /// and the JSON5 hex spellings (`0xFF`, `-0X10`, `+0x2A`) + /// preserved as raw text under `YYJSON_READ_NUMBER_AS_RAW`. + /// Returns `nil` for fractional or exponential text + /// so callers can fall through to `Decimal`/`Double` + /// instead of silently truncating through `Double`. + @inline(__always) + fileprivate func yyParseStrictInteger(_ text: String) -> T? { + return text.withCString { ptr -> T? in + let len = strlen(ptr) + guard len > 0 else { return nil } + var end: UnsafeMutablePointer? + errno = 0 + if T.isSigned { + let v = strtoll(ptr, &end, 0) + guard errno == 0, let e = end, ptr.distance(to: UnsafePointer(e)) == Int(len) + else { return nil } + return T(exactly: v) + } + if ptr.pointee == 0x2D /* '-' */ { return nil } + let v = strtoull(ptr, &end, 0) + guard errno == 0, let e = end, ptr.distance(to: UnsafePointer(e)) == Int(len) + else { return nil } + return T(exactly: v) + } + } + +#endif // !YYJSON_DISABLE_READER + /// An object that converts between JSON and the equivalent Foundation objects. /// This provides a drop-in replacement for Foundation's JSONSerialization using yyjson. public enum YYJSONSerialization { @@ -94,6 +135,10 @@ public enum YYJSONSerialization { readOptions.insert(.json5) } #endif + // Preserve the original text of every JSON number + // so that fractional values (e.g. `0.1`) round-trip exactly through `NSDecimalNumber`, + // matching the precision contract of Foundation's `JSONSerialization`. + readOptions.insert(.numberAsRaw) let document = try YYDocument(data: data, options: readOptions) guard let root = document.root else { @@ -429,6 +474,35 @@ public enum YYJSONSerialization { return NSNumber(value: b) } + // Numbers parsed with `YYJSON_READ_NUMBER_AS_RAW` arrive as raw text. + // Map them to the most precise NSNumber representation: + // signed/unsigned 64-bit integers when possible, then `NSDecimalNumber` + // for fractional or oversized integer values, and finally `Double` as a + // last resort for values outside `Decimal`'s representable range. + // + // JSON5 extended numbers (`0xFF`, `Infinity`, …) preserved as raw text + // are parsed via the C runtime through `yyParseDouble` + // so they survive the round-trip. + // The integer paths use `Int64`/`UInt64` initializers to reject + // fractional/exponential text (which would otherwise truncate through `Double`). + if let raw = rawValue, + yyjson_is_raw(raw), + let text = yyRawText(raw) + { + if let intVal: Int64 = yyParseStrictInteger(text) { + return NSNumber(value: intVal) + } + if let uintVal: UInt64 = yyParseStrictInteger(text) { + return NSNumber(value: uintVal) + } + if let dec = Decimal(string: text, locale: yyPOSIXLocale), !dec.isNaN { + return NSDecimalNumber(decimal: dec) + } + if let dbl = yyParseDouble(raw), dbl.isFinite { + return NSNumber(value: dbl) + } + } + if let n = number { if n.truncatingRemainder(dividingBy: 1) == 0 { if n >= Double(Int64.min) && n <= Double(Int64.max) { diff --git a/Sources/YYJSON/Value.swift b/Sources/YYJSON/Value.swift index cdef8e6..214c7fe 100644 --- a/Sources/YYJSON/Value.swift +++ b/Sources/YYJSON/Value.swift @@ -3,6 +3,19 @@ import Foundation #if !YYJSON_DISABLE_READER + /// Returns the verbatim text of a `YYJSON_TYPE_RAW` value + /// (numbers preserved via `YYJSON_READ_NUMBER_AS_RAW` or `YYJSON_READ_BIGNUM_AS_RAW`). + @inline(__always) + internal func yyRawText(_ val: UnsafeMutablePointer) -> String? { + guard yyjson_is_raw(val), let ptr = unsafe_yyjson_get_raw(val) else { return nil } + let len = unsafe_yyjson_get_len(val) + let buf = UnsafeBufferPointer( + start: UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self), + count: len + ) + return String(decoding: buf, as: UTF8.self) + } + // MARK: - Document (Internal) /// A safe wrapper around a yyjson document. @@ -216,6 +229,10 @@ import Foundation case numberInt(Int64, UnsafeMutablePointer) /// A JSON floating-point number stored as `Double`, with its yyjson value pointer. case numberDouble(Double, UnsafeMutablePointer) + /// A JSON number preserved as its original input text, + /// produced when parsing with `YYJSONReadOptions.numberAsRaw` + /// or `YYJSONReadOptions.bigNumberAsRaw`. + case numberRaw(UnsafeMutablePointer) /// A JSON string backed by a C string pointer and its yyjson value pointer. case stringPtr(UnsafePointer, UnsafeMutablePointer) /// A JSON object value pointer. @@ -240,6 +257,8 @@ import Foundation return ptr case .numberDouble(_, let ptr): return ptr + case .numberRaw(let ptr): + return ptr case .stringPtr(_, let ptr): return ptr case .object(let ptr): @@ -276,6 +295,8 @@ import Foundation } else { self.storage = .numberDouble(yyjson_get_real(val), val) } + case YYJSON_TYPE_RAW: + self.storage = .numberRaw(val) case YYJSON_TYPE_STR: if let str = yyjson_get_str(val) { self.storage = .stringPtr(str, val) @@ -344,12 +365,41 @@ import Foundation } /// The number value, or `nil` if not a number. + /// + /// Raw numeric text (parsed under `YYJSONReadOptions.numberAsRaw` or `bigNumberAsRaw`) + /// is run through the same `strtoll`/`strtoull`/`strtod` pipeline the decoder uses, + /// so JSON5 extras like hex literals (`0xFF`) and non-finite spellings (`Infinity`, `NaN`) + /// surface here as `Double` instead of returning `nil`. public var number: Double? { switch storage { case .numberInt(let value, _): return Double(value) case .numberDouble(let value, _): return value + case .numberRaw(let ptr): + return yyParseDouble(ptr) + default: + return nil + } + } + + /// The exact decimal value, or `nil` if not a number. + /// + /// When the underlying document was parsed with `YYJSONReadOptions.numberAsRaw` + /// (or for big numbers via `YYJSONReadOptions.bigNumberAsRaw`), + /// the number's original input text is parsed into a `Decimal` losslessly. + /// Otherwise the value is reconstructed from yyjson's parsed `Int64`/`Double` storage, + /// which is only approximate for non-integer values. + public var decimal: Decimal? { + switch storage { + case .numberInt(let value, _): + return Decimal(value) + case .numberDouble(let value, _): + guard value.isFinite else { return nil } + return Decimal(string: String(value), locale: yyPOSIXLocale) + case .numberRaw(let ptr): + guard let text = yyRawText(ptr) else { return nil } + return Decimal(string: text, locale: yyPOSIXLocale) default: return nil } @@ -385,6 +435,8 @@ import Foundation return String(n) case .numberDouble(let n, _): return String(n) + case .numberRaw(let ptr): + return yyRawText(ptr) ?? "null" case .stringPtr(let ptr, _): return "\"\(String(cString: ptr))\"" case .object(let ptr): diff --git a/Tests/YYJSONTests/DecoderTests.swift b/Tests/YYJSONTests/DecoderTests.swift index 3dd354f..4d94271 100644 --- a/Tests/YYJSONTests/DecoderTests.swift +++ b/Tests/YYJSONTests/DecoderTests.swift @@ -675,6 +675,343 @@ import Testing } } + // MARK: - Decimal Decoding Tests + + @Suite("YYJSONDecoder - Decimal") + struct DecoderDecimalTests { + struct DecimalContainer: Codable, Equatable { + let value: Decimal + } + + @Test func decodeDecimalFromInteger() throws { + let json = #"{"value": 42}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(42)) + } + + @Test func decodeDecimalFromNegativeInteger() throws { + let json = #"{"value": -7}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(-7)) + } + + @Test func decodeDecimalFromFraction() throws { + let json = #"{"value": 0.01}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "0.01")!) + } + + @Test func decodeDecimalPreservesPrecisionAcrossIncrements() throws { + let decoder = YYJSONDecoder() + var decimal = Decimal(string: "0.00")! + let limit = Decimal(string: "99.99")! + let step = Decimal(string: "0.01")! + while decimal <= limit { + let text = NSDecimalNumber(decimal: decimal).description(withLocale: yyPOSIXLocale) + let jsonData = Data("{\"value\":\(text)}".utf8) + let result = try decoder.decode(DecimalContainer.self, from: jsonData) + #expect(result.value == decimal) + decimal += step + } + } + + @Test func decodeDecimalFromTopLevelNumber() throws { + let data = Data("3.14159".utf8) + let result = try YYJSONDecoder().decode(Decimal.self, from: data) + #expect(result == Decimal(string: "3.14159")!) + } + + @Test func decodeDecimalInArray() throws { + let json = #"[1.5, 2.5, 3.5]"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode([Decimal].self, from: data) + #expect( + result == [ + Decimal(string: "1.5")!, + Decimal(string: "2.5")!, + Decimal(string: "3.5")!, + ] + ) + } + + @Test func decodeDecimalFromStringThrows() throws { + let json = #"{"value": "1.23"}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalFromZero() throws { + let json = #"{"value": 0}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal.zero) + } + + @Test func decodeDecimalFromZeroPointZero() throws { + let json = #"{"value": 0.00}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal.zero) + } + + @Test func decodeDecimalFromNegativeFraction() throws { + let json = #"{"value": -3.14}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "-3.14")!) + } + + @Test func decodeDecimalFromPositiveExponent() throws { + let json = #"{"value": 1.5e3}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "1500")!) + } + + @Test func decodeDecimalFromNegativeExponent() throws { + let json = #"{"value": 2.5e-2}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "0.025")!) + } + + @Test func decodeDecimalFromBoolThrows() throws { + let json = #"{"value": true}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalFromObjectThrows() throws { + let json = #"{"value": {}}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalFromArrayThrows() throws { + let json = #"{"value": []}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalFromNullThrows() throws { + let json = #"{"value": null}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeOptionalDecimalFromNull() throws { + struct Container: Codable { + let value: Decimal? + } + let json = #"{"value": null}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(Container.self, from: data) + #expect(result.value == nil) + } + + @Test func decodeOptionalDecimalFromMissingKey() throws { + struct Container: Codable { + let value: Decimal? + } + let json = #"{}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(Container.self, from: data) + #expect(result.value == nil) + } + + @Test func decodeOptionalDecimalWithValue() throws { + struct Container: Codable { + let value: Decimal? + } + let json = #"{"value": 1.5}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(Container.self, from: data) + #expect(result.value == Decimal(string: "1.5")!) + } + + @Test func decodeNestedDecimal() throws { + struct Inner: Codable, Equatable { + let amount: Decimal + } + struct Outer: Codable, Equatable { + let inner: Inner + } + let json = #"{"inner": {"amount": 99.99}}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(Outer.self, from: data) + #expect(result.inner.amount == Decimal(string: "99.99")!) + } + + @Test func decodeDictionaryOfDecimals() throws { + let json = #"{"a": 1.5, "b": 2.5, "c": -3.5}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode([String: Decimal].self, from: data) + #expect( + result == [ + "a": Decimal(string: "1.5")!, + "b": Decimal(string: "2.5")!, + "c": Decimal(string: "-3.5")!, + ] + ) + } + + @Test func decodeStructWithMultipleDecimalFields() throws { + struct Money: Codable, Equatable { + let amount: Decimal + let tax: Decimal + let total: Decimal + } + let json = #"{"amount": 100.00, "tax": 8.25, "total": 108.25}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(Money.self, from: data) + #expect( + result + == Money( + amount: Decimal(100), + tax: Decimal(string: "8.25")!, + total: Decimal(string: "108.25")! + ) + ) + } + + @Test func decodeDecimalInNestedArray() throws { + let json = "[[1.1, 2.2], [3.3, 4.4]]" + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode([[Decimal]].self, from: data) + #expect( + result == [ + [Decimal(string: "1.1")!, Decimal(string: "2.2")!], + [Decimal(string: "3.3")!, Decimal(string: "4.4")!], + ] + ) + } + + @Test func decodeDecimalWithSnakeCaseKey() throws { + struct Container: Codable, Equatable { + let unitPrice: Decimal + } + let json = #"{"unit_price": 19.99}"# + let data = json.data(using: .utf8)! + var decoder = YYJSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let result = try decoder.decode(Container.self, from: data) + #expect(result.unitPrice == Decimal(string: "19.99")!) + } + + @Test func decodeDecimalReportsPathOnTypeMismatch() throws { + struct Container: Codable { + let nested: Inner + struct Inner: Codable { + let amount: Decimal + } + } + let json = #"{"nested": {"amount": "oops"}}"# + let data = json.data(using: .utf8)! + do { + _ = try YYJSONDecoder().decode(Container.self, from: data) + Issue.record("Expected decoding to throw") + } catch let error as YYJSONError { + #expect(error.path.contains("amount")) + } + } + + @Test func decodeDecimalInSingleValueContainer() throws { + struct Wrapper: Decodable, Equatable { + let decimal: Decimal + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.decimal = try container.decode(Decimal.self) + } + } + let data = Data("42.5".utf8) + let result = try YYJSONDecoder().decode(Wrapper.self, from: data) + #expect(result.decimal == Decimal(string: "42.5")!) + } + + // MARK: - Overflow / Underflow + + @Test func decodeDecimalWithinDecimalRange() throws { + // 1e100 fits in both Double and Decimal exponent ranges. + let json = #"{"value": 1e100}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "1e100")!) + } + + @Test func decodeDecimalOverflowingDecimalRangeThrows() throws { + // 1e200 fits in Double but exceeds Decimal's exponent range (-128...127), + // so `Decimal(string:)` returns nil + // and we surface a clear error. + let json = #"{"value": 1e200}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalUnderflowingDecimalRangeThrows() throws { + // 1e-200 is non-zero in Double but below Decimal's smallest representable magnitude, + // so `Decimal(string:)` returns nil. + let json = #"{"value": 1e-200}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalOverflowingDoubleThrows() throws { + // 1e1000 overflows Double; yyjson rejects this as an invalid number + // before our Decimal-specific path is reached. + let json = #"{"value": 1e1000}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeDecimalUnderflowingDecimalThrows() throws { + // 1e-500 is below Decimal's smallest representable magnitude. + // The decoder parses the raw text directly rather than collapsing to zero through a Double conversion, + // so we surface a clear error. + let json = #"{"value": 1e-500}"# + let data = json.data(using: .utf8)! + #expect(throws: YYJSONError.self) { + _ = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + } + } + + @Test func decodeIntegerExceedingUInt64MaxPreservesPrecision() throws { + // The decoder reads numbers as raw text, + // so 20+ digit integers that exceed UInt64 still decode losslessly into Decimal. + let json = #"{"value": 99999999999999999999}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "99999999999999999999")!) + } + + @Test func decodeDecimalPreservesPrecisionBeyondDouble() throws { + // The decoder bypasses Double entirely for Decimal, + // so fractional values with more than Double's ~17 significant digits decode exactly. + let json = #"{"value": 0.12345678901234567890123456789012345678}"# + let data = json.data(using: .utf8)! + let result = try YYJSONDecoder().decode(DecimalContainer.self, from: data) + #expect(result.value == Decimal(string: "0.12345678901234567890123456789012345678")!) + } + } + // MARK: - Non-Conforming Float Strategy Tests @Suite("YYJSONDecoder - Non-Conforming Float Strategies") @@ -792,6 +1129,28 @@ import Testing let result = try decoder.decode(SimpleStruct.self, from: data) #expect(result.name == "test") } + + @Test func decodeJSON5HexLiteralAsInteger() throws { + // Under `.lossless` (the default), + // `YYJSON_READ_NUMBER_AS_RAW` preserves hex literals as raw text. + // The raw-number parsers must auto-detect the `0x` prefix so hex still decodes. + let json = #"{"name": "x", "value": 0xFF}"# + let data = json.data(using: .utf8)! + var decoder = YYJSONDecoder() + decoder.allowsJSON5 = true + let result = try decoder.decode(SimpleStruct.self, from: data) + #expect(result.value == 255) + } + + @Test func decodeJSON5HexLiteralAsDouble() throws { + struct Container: Decodable { let value: Double } + let json = #"{"value": 0x10}"# + let data = json.data(using: .utf8)! + var decoder = YYJSONDecoder() + decoder.allowsJSON5 = true + let result = try decoder.decode(Container.self, from: data) + #expect(result.value == 16.0) + } } #endif // !YYJSON_DISABLE_NON_STANDARD @@ -1030,4 +1389,55 @@ import Testing } } + @Suite("YYJSONDecoder - NumberDecodingStrategy") + struct DecoderNumberStrategyTests { + struct Container: Codable, Equatable { + let value: Decimal + } + + @Test func defaultsToLossless() { + #expect(YYJSONDecoder().numberDecodingStrategy == .lossless) + } + + @Test func fastModeDecodesOrdinaryNumbers() throws { + var decoder = YYJSONDecoder() + decoder.numberDecodingStrategy = .fast + let data = Data("[1, 2.5, -3, 4e2]".utf8) + let result = try decoder.decode([Double].self, from: data) + #expect(result == [1.0, 2.5, -3.0, 400.0]) + } + + @Test func fastModeLosesFractionalDecimalPrecision() throws { + // A value with more significant digits than `Double` can hold. + // `.lossless` parses the original text directly; + // `.fast` routes through `Double` and recovers only ~17 significant digits. + let json = #"{"value": 1.234567890123456789012345}"# + let data = Data(json.utf8) + var fast = YYJSONDecoder() + fast.numberDecodingStrategy = .fast + let lossless = YYJSONDecoder() + let fastResult = try fast.decode(Container.self, from: data) + let losslessResult = try lossless.decode(Container.self, from: data) + let exact = Decimal(string: "1.234567890123456789012345")! + #expect(losslessResult.value == exact) + #expect(fastResult.value != exact) + } + + @Test func fastModeLosesPrecisionForIntegersBeyondUInt64() throws { + // 22-digit integer is well beyond UInt64.max. + // `.lossless` decodes it into Decimal exactly; + // `.fast` parses it as `Double` and the original digits are clipped to Double's precision. + let json = #"{"value": 1234567890123456789012}"# + let data = Data(json.utf8) + var fast = YYJSONDecoder() + fast.numberDecodingStrategy = .fast + let lossless = YYJSONDecoder() + let exact = Decimal(string: "1234567890123456789012")! + let fastResult = try fast.decode(Container.self, from: data) + let losslessResult = try lossless.decode(Container.self, from: data) + #expect(losslessResult.value == exact) + #expect(fastResult.value != exact) + } + } + #endif // !YYJSON_DISABLE_READER diff --git a/Tests/YYJSONTests/EncoderTests.swift b/Tests/YYJSONTests/EncoderTests.swift index feffb3e..8f0deba 100644 --- a/Tests/YYJSONTests/EncoderTests.swift +++ b/Tests/YYJSONTests/EncoderTests.swift @@ -1176,4 +1176,256 @@ import Testing } } + // MARK: - Decimal Encoding Tests + + @Suite("YYJSONEncoder - Decimal") + struct EncoderDecimalTests { + struct DecimalContainer: Codable, Equatable { + let value: Decimal + } + + @Test func encodeDecimalAsJSONNumber() throws { + let container = DecimalContainer(value: Decimal(string: "0.01")!) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":0.01}"#) + } + + @Test func encodeDecimalInteger() throws { + let container = DecimalContainer(value: Decimal(42)) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":42}"#) + } + + @Test func encodeDecimalArray() throws { + let values = [ + Decimal(string: "1.5")!, + Decimal(string: "2.5")!, + Decimal(string: "-3.75")!, + ] + let encoded = try YYJSONEncoder().encode(values) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == "[1.5,2.5,-3.75]") + } + + @Test func encodeDecimalAsTopLevelValue() throws { + let encoded = try YYJSONEncoder().encode(Decimal(string: "3.14159")!) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == "3.14159") + } + + @Test func roundtripDecimalPreservesPrecision() throws { + let encoder = YYJSONEncoder() + let decoder = YYJSONDecoder() + let samples: [Decimal] = [ + Decimal.zero, + Decimal(string: "0.01")!, + Decimal(string: "0.1")!, + Decimal(string: "0.5")!, + Decimal(string: "1.00")!, + Decimal(string: "3.14159")!, + Decimal(string: "42.42")!, + Decimal(string: "99.99")!, + Decimal(string: "-0.01")!, + Decimal(string: "-99.99")!, + Decimal(string: "0.123456789012345678")!, + ] + for decimal in samples { + let container = DecimalContainer(value: decimal) + let encoded = try encoder.encode(container) + let decoded = try decoder.decode(DecimalContainer.self, from: encoded) + #expect(decoded == container) + } + } + + @Test func encodeNegativeDecimal() throws { + let container = DecimalContainer(value: Decimal(string: "-99.99")!) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":-99.99}"#) + } + + @Test func encodeZeroDecimal() throws { + let container = DecimalContainer(value: Decimal.zero) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":0}"#) + } + + @Test func encodeHighPrecisionDecimal() throws { + let value = Decimal(string: "0.123456789012345678")! + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":0.123456789012345678}"#) + } + + @Test func encodeDecimalWithManyDigits() throws { + let value = Decimal(string: "12345678901234567890")! + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":12345678901234567890}"#) + } + + @Test func encodeNaNDecimalThrows() throws { + let container = DecimalContainer(value: Decimal.nan) + #expect(throws: YYJSONError.self) { + _ = try YYJSONEncoder().encode(container) + } + } + + @Test func encodeNaNDecimalAtTopLevelThrows() throws { + #expect(throws: YYJSONError.self) { + _ = try YYJSONEncoder().encode(Decimal.nan) + } + } + + @Test func encodeOptionalDecimalWithValue() throws { + struct Container: Codable { + let value: Decimal? + } + let container = Container(value: Decimal(string: "1.5")!) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"value":1.5}"#) + } + + @Test func encodeOptionalDecimalNilIsOmitted() throws { + struct Container: Codable { + let value: Decimal? + } + let container = Container(value: nil) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{}"#) + } + + @Test func encodeNestedDecimal() throws { + struct Inner: Codable { + let amount: Decimal + } + struct Outer: Codable { + let inner: Inner + } + let container = Outer(inner: Inner(amount: Decimal(string: "99.99")!)) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"inner":{"amount":99.99}}"#) + } + + @Test func encodeStructWithMultipleDecimalFields() throws { + struct Money: Codable { + let amount: Decimal + let tax: Decimal + let total: Decimal + } + let money = Money( + amount: Decimal(100), + tax: Decimal(string: "8.25")!, + total: Decimal(string: "108.25")! + ) + var encoder = YYJSONEncoder() + encoder.writeOptions = .sortedKeys + let encoded = try encoder.encode(money) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"amount":100,"tax":8.25,"total":108.25}"#) + } + + @Test func encodeDecimalInDictionary() throws { + let dict: [String: Decimal] = ["price": Decimal(string: "9.99")!] + let encoded = try YYJSONEncoder().encode(dict) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == #"{"price":9.99}"#) + } + + @Test func encodeDecimalInNestedArray() throws { + let values: [[Decimal]] = [ + [Decimal(string: "1.1")!, Decimal(string: "2.2")!], + [Decimal(string: "3.3")!, Decimal(string: "4.4")!], + ] + let encoded = try YYJSONEncoder().encode(values) + let result = String(data: encoded, encoding: .utf8)! + #expect(result == "[[1.1,2.2],[3.3,4.4]]") + } + + @Test func encodedDecimalIsJSONNumberNotString() throws { + let container = DecimalContainer(value: Decimal(string: "42.5")!) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(!result.contains("\"42.5\"")) + #expect(result.contains("42.5")) + } + + @Test func encodedDecimalLooksLikeForeignJSONEncoder() throws { + struct Container: Codable { + let value: Decimal + } + let value = Decimal(string: "1234.5678")! + let container = Container(value: value) + let foundation = try JSONEncoder().encode(container) + let ours = try YYJSONEncoder().encode(container) + #expect(String(data: foundation, encoding: .utf8) == String(data: ours, encoding: .utf8)) + } + + // MARK: - Overflow / Underflow + + @Test func encodeGreatestFiniteMagnitude() throws { + // The encoder writes the Decimal's exact text without going through Double, + // so even values beyond Double's range serialize losslessly. + let value = Decimal.greatestFiniteMagnitude + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result.contains(NSDecimalNumber(decimal: value).description(withLocale: yyPOSIXLocale))) + } + + @Test func encodeLeastFiniteMagnitude() throws { + let value = Decimal.leastFiniteMagnitude + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result.contains(NSDecimalNumber(decimal: value).description(withLocale: yyPOSIXLocale))) + } + + @Test func encodeLeastNormalMagnitude() throws { + let value = Decimal.leastNormalMagnitude + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result.contains(NSDecimalNumber(decimal: value).description(withLocale: yyPOSIXLocale))) + } + + @Test func encodePreservesFullPrecisionBeyondDouble() throws { + // The encoder bypasses Double entirely, + // so the JSON output retains every digit even when re-decoding would lose precision. + let value = Decimal(string: "0.12345678901234567890123456789012345678")! + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let result = String(data: encoded, encoding: .utf8)! + #expect(result.contains("0.12345678901234567890123456789012345678")) + } + + @Test func decimalRoundtripBeyondDoublePreservesPrecision() throws { + // Both encoding and decoding bypass Double for Decimal: + // the encoder writes the raw text and the decoder parses the raw text. + // This makes round-trips lossless even past Double's ~17-digit precision. + let value = Decimal(string: "0.12345678901234567890123456789012345678")! + let container = DecimalContainer(value: value) + let encoded = try YYJSONEncoder().encode(container) + let decoded = try YYJSONDecoder().decode(DecimalContainer.self, from: encoded) + #expect(decoded.value == value) + } + + @Test func roundtripGreatestFiniteMagnitude() throws { + // greatestFiniteMagnitude overflows Double, + // but the raw-text path through both encoder and decoder preserves it losslessly. + let container = DecimalContainer(value: Decimal.greatestFiniteMagnitude) + let encoded = try YYJSONEncoder().encode(container) + let decoded = try YYJSONDecoder().decode(DecimalContainer.self, from: encoded) + #expect(decoded.value == Decimal.greatestFiniteMagnitude) + } + } + #endif // !YYJSON_DISABLE_WRITER && !YYJSON_DISABLE_READER diff --git a/Tests/YYJSONTests/SerializationTests.swift b/Tests/YYJSONTests/SerializationTests.swift index db015c0..8d6a54d 100644 --- a/Tests/YYJSONTests/SerializationTests.swift +++ b/Tests/YYJSONTests/SerializationTests.swift @@ -133,6 +133,21 @@ import Testing #expect(result?["key"] as? String == "value") } + @Test func readJSON5HexLiteralAsInteger() throws { + // `YYJSONSerialization` always reads numbers as raw text, + // so a JSON5 hex literal arrives as `"0xFF"` + // and must round-trip as an integer-valued `NSNumber` + // rather than falling through to `NSNull` (which is what `Int64("0xFF")` would do). + let json = #"{"hex": 0xFF}"# + let data = json.data(using: .utf8)! + let result = + try YYJSONSerialization.jsonObject( + with: data, + options: .json5Allowed + ) as? NSDictionary + #expect(result?["hex"] as? Int == 255) + } + #endif // !YYJSON_DISABLE_NON_STANDARD @Test func readInvalidJSON() throws { @@ -144,6 +159,73 @@ import Testing } } + // MARK: - Numeric Precision Tests + + @Suite("YYJSONSerialization - Number Precision") + struct SerializationNumberPrecisionTests { + @Test func fractionalNumbersDecodeAsNSDecimalNumber() throws { + let json = #"{"price": 0.1}"# + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + let value = result?["price"] + #expect(value is NSDecimalNumber) + #expect((value as? NSDecimalNumber)?.decimalValue == Decimal(string: "0.1")) + } + + @Test func fractionalDecodingPreservesPrecisionAcrossIncrements() throws { + var expected = Decimal(string: "0.00")! + let step = Decimal(string: "0.01")! + while expected <= Decimal(string: "1.00")! { + let text = NSDecimalNumber(decimal: expected).description(withLocale: yyPOSIXLocale) + let json = "{\"v\":\(text)}" + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + // Whole-number values come back as integer-typed NSNumber; + // fractional values come back as NSDecimalNumber. + // Both bridge to NSNumber, whose `decimalValue` recovers the original Decimal losslessly. + #expect((result?["v"] as? NSNumber)?.decimalValue == expected) + expected += step + } + } + + @Test func integersStillDecodeAsNSNumberInt() throws { + let json = #"{"answer": 42, "negative": -7}"# + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + #expect(result?["answer"] as? Int == 42) + #expect(result?["negative"] as? Int == -7) + } + + @Test func bigIntegersBeyondInt64DecodeAsNSDecimalNumber() throws { + let big = "99999999999999999999" + let json = "{\"big\":\(big)}" + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + let value = result?["big"] + #expect(value is NSDecimalNumber) + #expect((value as? NSDecimalNumber)?.decimalValue == Decimal(string: big)) + } + + @Test func unsignedIntegersBeyondInt64DecodeAsNSNumber() throws { + // 2^63 fits in UInt64 but not in Int64. + let json = "{\"v\":9223372036854775808}" + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + let value = result?["v"] + #expect(value is NSNumber) + #expect((value as? NSNumber)?.uint64Value == 9_223_372_036_854_775_808) + } + + @Test func decimalNumberBridgesBackToDouble() throws { + let json = #"{"x": 3.14159265358979}"# + let data = json.data(using: .utf8)! + let result = try YYJSONSerialization.jsonObject(with: data) as? NSDictionary + let value = result?["x"] as? NSNumber + #expect(value != nil) + #expect(abs(value!.doubleValue - 3.14159265358979) < 1e-13) + } + } + #endif // !YYJSON_DISABLE_READER // MARK: - JSONObject Writing Tests diff --git a/Tests/YYJSONTests/ValueTests.swift b/Tests/YYJSONTests/ValueTests.swift index f40d508..eaf4607 100644 --- a/Tests/YYJSONTests/ValueTests.swift +++ b/Tests/YYJSONTests/ValueTests.swift @@ -358,6 +358,101 @@ import Testing } } + // MARK: - YYJSONValue Decimal / Raw Number Tests + + @Suite("YYJSONValue - Decimal") + struct ValueDecimalTests { + @Test func decimalFromInteger() throws { + let value = try YYJSONValue(string: "42") + #expect(value.decimal == Decimal(42)) + } + + @Test func decimalFromNegativeInteger() throws { + let value = try YYJSONValue(string: "-123") + #expect(value.decimal == Decimal(-123)) + } + + @Test func decimalFromFractionWithoutRawIsApproximate() throws { + let value = try YYJSONValue(string: "0.1") + // Without `.numberAsRaw`, + // the value passes through `Double`, + // so the returned `Decimal` reflects the closest binary representation. + let approximate = value.decimal + #expect(approximate != nil) + #expect(abs((approximate! as NSDecimalNumber).doubleValue - 0.1) < 1e-9) + } + + @Test func decimalFromRawIsExact() throws { + let value = try YYJSONValue(string: "0.1", options: .numberAsRaw) + #expect(value.decimal == Decimal(string: "0.1")) + } + + @Test func decimalPreservesPrecisionAcrossIncrements() throws { + var expected = Decimal(string: "0.00")! + let step = Decimal(string: "0.01")! + while expected <= Decimal(string: "1.00")! { + let text = NSDecimalNumber(decimal: expected).description(withLocale: yyPOSIXLocale) + let value = try YYJSONValue(string: text, options: .numberAsRaw) + #expect(value.decimal == expected) + expected += step + } + } + + @Test func decimalFromBigIntegerWithRaw() throws { + let big = "99999999999999999999" + let value = try YYJSONValue(string: big, options: .numberAsRaw) + #expect(value.decimal == Decimal(string: big)) + } + + @Test func numberStillWorksForRawValues() throws { + let value = try YYJSONValue(string: "3.14159", options: .numberAsRaw) + #expect(value.number != nil) + #expect(abs(value.number! - 3.14159) < 1e-9) + } + + #if !YYJSON_DISABLE_NON_STANDARD + + @Test func numberParsesJSON5HexFromRawText() throws { + // With `.numberAsRaw` + `.allowExtendedNumbers`, hex literals are preserved as raw text. + // `.number` must route through the C integer fast path (base 0) + // so the hex value still surfaces as a `Double` instead of returning `nil`. + let value = try YYJSONValue( + string: "0xFF", + options: [.numberAsRaw, .allowExtendedNumbers] + ) + #expect(value.number == 255.0) + } + + @Test func numberParsesInfinityFromRawText() throws { + let value = try YYJSONValue( + string: "Infinity", + options: [.numberAsRaw, .allowInfAndNaN] + ) + #expect(value.number?.isInfinite == true) + } + + #endif // !YYJSON_DISABLE_NON_STANDARD + + @Test func descriptionPreservesRawNumberText() throws { + let value = try YYJSONValue(string: "1.0000000000000001", options: .numberAsRaw) + #expect(value.description == "1.0000000000000001") + } + + @Test func decimalReturnsNilForNonNumber() throws { + #expect(try YYJSONValue(string: #""hello""#).decimal == nil) + #expect(try YYJSONValue(string: "true").decimal == nil) + #expect(try YYJSONValue(string: "null").decimal == nil) + } + + @Test func nestedRawNumberAccess() throws { + let json = #"{"price": 19.99, "items": [0.1, 0.2, 0.3]}"# + let value = try YYJSONValue(string: json, options: .numberAsRaw) + #expect(value["price"]?.decimal == Decimal(string: "19.99")) + #expect(value["items"]?[0]?.decimal == Decimal(string: "0.1")) + #expect(value["items"]?[2]?.decimal == Decimal(string: "0.3")) + } + } + // MARK: - YYJSONObject Tests @Suite("YYJSONObject - Direct Access")