Skip to content

[4.0] Encode Decimal as a numeric value in JSON #10553

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

Merged
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
20 changes: 19 additions & 1 deletion stdlib/public/SDK/Foundation/JSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ open class JSONEncoder {
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "Top-level \(T.self) encoded as string JSON fragment."))
}

var writingOptions = JSONSerialization.WritingOptions(rawValue: self.outputFormatting.rawValue)
let writingOptions = JSONSerialization.WritingOptions(rawValue: self.outputFormatting.rawValue)
return try JSONSerialization.data(withJSONObject: topLevel, options: writingOptions)
}
}
Expand Down Expand Up @@ -688,6 +688,9 @@ extension _JSONEncoder {
} else if T.self == URL.self {
// Encode URLs as single strings.
return self.box((value as! URL).absoluteString)
} else if T.self == Decimal.self {
// JSONSerialization can natively handle NSDecimalNumber.
return (value as! Decimal) as NSDecimalNumber
}

// The value should request a container from the _JSONEncoder.
Expand Down Expand Up @@ -1948,6 +1951,19 @@ extension _JSONDecoder {
}
}

func unbox(_ value: Any?, as type: Decimal.Type) throws -> Decimal? {
guard let value = value else { return nil }
guard !(value is NSNull) else { return nil }

// Attempt to bridge from NSDecimalNumber.
if let decimal = value as? Decimal {
return decimal
} else {
let doubleValue = try self.unbox(value, as: Double.self)!
return Decimal(doubleValue)
}
}

func unbox<T : Decodable>(_ value: Any?, as type: T.Type) throws -> T? {
guard let value = value else { return nil }
guard !(value is NSNull) else { return nil }
Expand All @@ -1968,6 +1984,8 @@ extension _JSONDecoder {
}

decoded = (url as! T)
} else if T.self == Decimal.self {
decoded = (try self.unbox(value, as: Decimal.self) as! T)
} else {
self.storage.push(container: value)
decoded = try T(from: self)
Expand Down
59 changes: 33 additions & 26 deletions test/stdlib/TestJSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
func testEncodingDateSecondsSince1970() {
// Cannot encode an arbitrary number of seconds since we've lost precision since 1970.
let seconds = 1000.0
let expectedJSON = "[1000]".data(using: .utf8)!
let expectedJSON = "{\"value\":1000}".data(using: .utf8)!

// We can't encode a top-level Date, so it'll be wrapped in an array.
_testRoundTrip(of: TopLevelWrapper(Date(timeIntervalSince1970: seconds)),
Expand All @@ -142,7 +142,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
func testEncodingDateMillisecondsSince1970() {
// Cannot encode an arbitrary number of seconds since we've lost precision since 1970.
let seconds = 1000.0
let expectedJSON = "[1000000]".data(using: .utf8)!
let expectedJSON = "{\"value\":1000000}".data(using: .utf8)!

// We can't encode a top-level Date, so it'll be wrapped in an array.
_testRoundTrip(of: TopLevelWrapper(Date(timeIntervalSince1970: seconds)),
Expand All @@ -157,7 +157,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
formatter.formatOptions = .withInternetDateTime

let timestamp = Date(timeIntervalSince1970: 1000)
let expectedJSON = "[\"\(formatter.string(from: timestamp))\"]".data(using: .utf8)!
let expectedJSON = "{\"value\":\"\(formatter.string(from: timestamp))\"}".data(using: .utf8)!

// We can't encode a top-level Date, so it'll be wrapped in an array.
_testRoundTrip(of: TopLevelWrapper(timestamp),
Expand All @@ -173,7 +173,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
formatter.timeStyle = .full

let timestamp = Date(timeIntervalSince1970: 1000)
let expectedJSON = "[\"\(formatter.string(from: timestamp))\"]".data(using: .utf8)!
let expectedJSON = "{\"value\":\"\(formatter.string(from: timestamp))\"}".data(using: .utf8)!

// We can't encode a top-level Date, so it'll be wrapped in an array.
_testRoundTrip(of: TopLevelWrapper(timestamp),
Expand All @@ -193,7 +193,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let decode = { (_: Decoder) throws -> Date in return timestamp }

// We can't encode a top-level Date, so it'll be wrapped in an array.
let expectedJSON = "[42]".data(using: .utf8)!
let expectedJSON = "{\"value\":42}".data(using: .utf8)!
_testRoundTrip(of: TopLevelWrapper(timestamp),
expectedJSON: expectedJSON,
dateEncodingStrategy: .custom(encode),
Expand All @@ -208,7 +208,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let decode = { (_: Decoder) throws -> Date in return timestamp }

// We can't encode a top-level Date, so it'll be wrapped in an array.
let expectedJSON = "[{}]".data(using: .utf8)!
let expectedJSON = "{\"value\":{}}".data(using: .utf8)!
_testRoundTrip(of: TopLevelWrapper(timestamp),
expectedJSON: expectedJSON,
dateEncodingStrategy: .custom(encode),
Expand All @@ -220,7 +220,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let data = Data(bytes: [0xDE, 0xAD, 0xBE, 0xEF])

// We can't encode a top-level Data, so it'll be wrapped in an array.
let expectedJSON = "[\"3q2+7w==\"]".data(using: .utf8)!
let expectedJSON = "{\"value\":\"3q2+7w==\"}".data(using: .utf8)!
_testRoundTrip(of: TopLevelWrapper(data), expectedJSON: expectedJSON)
}

Expand All @@ -233,7 +233,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let decode = { (_: Decoder) throws -> Data in return Data() }

// We can't encode a top-level Data, so it'll be wrapped in an array.
let expectedJSON = "[42]".data(using: .utf8)!
let expectedJSON = "{\"value\":42}".data(using: .utf8)!
_testRoundTrip(of: TopLevelWrapper(Data()),
expectedJSON: expectedJSON,
dataEncodingStrategy: .custom(encode),
Expand All @@ -246,7 +246,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let decode = { (_: Decoder) throws -> Data in return Data() }

// We can't encode a top-level Data, so it'll be wrapped in an array.
let expectedJSON = "[{}]".data(using: .utf8)!
let expectedJSON = "{\"value\":{}}".data(using: .utf8)!
_testRoundTrip(of: TopLevelWrapper(Data()),
expectedJSON: expectedJSON,
dataEncodingStrategy: .custom(encode),
Expand All @@ -270,32 +270,32 @@ class TestJSONEncoder : TestJSONEncoderSuper {


_testRoundTrip(of: TopLevelWrapper(Float.infinity),
expectedJSON: "[\"INF\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"INF\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)
_testRoundTrip(of: TopLevelWrapper(-Float.infinity),
expectedJSON: "[\"-INF\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"-INF\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)

// Since Float.nan != Float.nan, we have to use a placeholder that'll encode NaN but actually round-trip.
_testRoundTrip(of: TopLevelWrapper(FloatNaNPlaceholder()),
expectedJSON: "[\"NaN\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"NaN\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)

_testRoundTrip(of: TopLevelWrapper(Double.infinity),
expectedJSON: "[\"INF\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"INF\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)
_testRoundTrip(of: TopLevelWrapper(-Double.infinity),
expectedJSON: "[\"-INF\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"-INF\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)

// Since Double.nan != Double.nan, we have to use a placeholder that'll encode NaN but actually round-trip.
_testRoundTrip(of: TopLevelWrapper(DoubleNaNPlaceholder()),
expectedJSON: "[\"NaN\"]".data(using: .utf8)!,
expectedJSON: "{\"value\":\"NaN\"}".data(using: .utf8)!,
nonConformingFloatEncodingStrategy: encodingStrategy,
nonConformingFloatDecodingStrategy: decodingStrategy)
}
Expand All @@ -319,6 +319,22 @@ class TestJSONEncoder : TestJSONEncoderSuper {
}
}

func testInterceptDecimal() {
let expectedJSON = "{\"value\":10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000}".data(using: .utf8)!

// Want to make sure we write out a JSON number, not the keyed encoding here.
// 1e127 is too big to fit natively in a Double, too, so want to make sure it's encoded as a Decimal.
let decimal = Decimal(sign: .plus, exponent: 127, significand: Decimal(1))
_testRoundTrip(of: TopLevelWrapper(decimal), expectedJSON: expectedJSON)
}

func testInterceptURL() {
// Want to make sure JSONEncoder writes out single-value URLs, not the keyed encoding.
let expectedJSON = "{\"value\":\"http:\\/\\/swift.org\"}".data(using: .utf8)!
let url = URL(string: "http://swift.org")!
_testRoundTrip(of: TopLevelWrapper(url), expectedJSON: expectedJSON)
}

// MARK: - Helper Functions
private var _jsonEmptyDictionary: Data {
return "{}".data(using: .utf8)!
Expand Down Expand Up @@ -644,17 +660,6 @@ fileprivate struct TopLevelWrapper<T> : Codable, Equatable where T : Codable, T
self.value = value
}

func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(value)
}

init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
value = try container.decode(T.self)
assert(container.isAtEnd)
}

static func ==(_ lhs: TopLevelWrapper<T>, _ rhs: TopLevelWrapper<T>) -> Bool {
return lhs.value == rhs.value
}
Expand Down Expand Up @@ -883,5 +888,7 @@ JSONEncoderTests.test("testEncodingNonConformingFloats") { TestJSONEncoder().tes
JSONEncoderTests.test("testEncodingNonConformingFloatStrings") { TestJSONEncoder().testEncodingNonConformingFloatStrings() }
JSONEncoderTests.test("testNestedContainerCodingPaths") { TestJSONEncoder().testNestedContainerCodingPaths() }
JSONEncoderTests.test("testSuperEncoderCodingPaths") { TestJSONEncoder().testSuperEncoderCodingPaths() }
JSONEncoderTests.test("testInterceptDecimal") { TestJSONEncoder().testInterceptDecimal() }
JSONEncoderTests.test("testInterceptURL") { TestJSONEncoder().testInterceptURL() }
runAllTests()
#endif
11 changes: 0 additions & 11 deletions test/stdlib/TestPlistEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,17 +594,6 @@ fileprivate struct TopLevelWrapper<T> : Codable, Equatable where T : Codable, T
self.value = value
}

func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(value)
}

init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
value = try container.decode(T.self)
assert(container.isAtEnd)
}

static func ==(_ lhs: TopLevelWrapper<T>, _ rhs: TopLevelWrapper<T>) -> Bool {
return lhs.value == rhs.value
}
Expand Down