Skip to content
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

Add useSnakeCasedKeys to JSONDecoder.KeyDecodingStrategy #14039

Closed
wants to merge 1 commit into from
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
72 changes: 50 additions & 22 deletions stdlib/public/SDK/Foundation/JSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,22 @@ open class JSONDecoder {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Convert from "camelCaseKeys" to "snake_case_keys" before accessing to JSON payload.
///
/// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` and `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt).
/// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means the result is consistent regardless of the current user's locale and language preferences.
///
/// Converting from camel case to snake case:
/// 1. Splits words at the boundary of lower-case to upper-case
/// 2. Inserts `_` between words
/// 3. Lowercases the entire string
/// 4. Preserves starting and ending `_`.
///
/// For example, `oneTwoThree` becomes `one_two_three`. `_oneTwoThree_` becomes `_one_two_three_`.
///
/// - Note: Using a key encoding strategy has a nominal performance cost, as each string key has to be converted.
case useSnakeCasedKeys

/// Convert from "snake_case_keys" to "camelCaseKeys" before attempting to match a key with the one specified by each type.
///
/// The conversion to upper case uses `Locale.system`, also known as the ICU "root" locale. This means the result is consistent regardless of the current user's locale and language preferences.
Expand All @@ -995,7 +1011,7 @@ open class JSONDecoder {
///
/// - Note: Using a key decoding strategy has a nominal performance cost, as each string key has to be inspected for the `_` character.
case convertFromSnakeCase

/// Provide a custom conversion from the key in the encoded JSON to the keys specified by the decoded types.
/// The full path to the current decoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before decoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the container for the type to decode from.
Expand Down Expand Up @@ -1233,7 +1249,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
fileprivate init(referencing decoder: _JSONDecoder, wrapping container: [String : Any]) {
self.decoder = decoder
switch decoder.options.keyDecodingStrategy {
case .useDefaultKeys:
case .useDefaultKeys, .useSnakeCasedKeys:
self.container = container
case .convertFromSnakeCase:
// Convert the snake case keys in the container to camel case.
Expand All @@ -1249,6 +1265,18 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
self.codingPath = decoder.codingPath
}

// MARK: - Coding Path Operations

private func _converted(_ key: CodingKey) -> CodingKey {
switch decoder.options.keyDecodingStrategy {
case .useSnakeCasedKeys:
let newKeyString = JSONEncoder.KeyEncodingStrategy._convertToSnakeCase(key.stringValue)
return _JSONKey(stringValue: newKeyString, intValue: key.intValue)
default:
return key
}
}

// MARK: - KeyedDecodingContainerProtocol Methods

public var allKeys: [Key] {
Expand All @@ -1261,7 +1289,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon

private func _errorDescription(of key: CodingKey) -> String {
switch decoder.options.keyDecodingStrategy {
case .convertFromSnakeCase:
case .convertFromSnakeCase, .useSnakeCasedKeys:
// In this case we can attempt to recover the original value by reversing the transform
let original = key.stringValue
let converted = JSONEncoder.KeyEncodingStrategy._convertToSnakeCase(original)
Expand All @@ -1277,15 +1305,15 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decodeNil(forKey key: Key) throws -> Bool {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

return entry is NSNull
}

public func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1300,7 +1328,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1315,7 +1343,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1330,7 +1358,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1345,7 +1373,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1360,7 +1388,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1375,7 +1403,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1390,7 +1418,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1405,7 +1433,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1420,7 +1448,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1435,7 +1463,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1450,7 +1478,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1465,7 +1493,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1480,7 +1508,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode(_ type: String.Type, forKey key: Key) throws -> String {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1495,7 +1523,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode<T : Decodable>(_ type: T.Type, forKey key: Key) throws -> T {
guard let entry = self.container[key.stringValue] else {
guard let entry = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}

Expand All @@ -1513,7 +1541,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
self.decoder.codingPath.append(key)
defer { self.decoder.codingPath.removeLast() }

guard let value = self.container[key.stringValue] else {
guard let value = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key,
DecodingError.Context(codingPath: self.codingPath,
debugDescription: "Cannot get \(KeyedDecodingContainer<NestedKey>.self) -- no value found for key \(_errorDescription(of: key))"))
Expand All @@ -1531,7 +1559,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
self.decoder.codingPath.append(key)
defer { self.decoder.codingPath.removeLast() }

guard let value = self.container[key.stringValue] else {
guard let value = self.container[_converted(key).stringValue] else {
throw DecodingError.keyNotFound(key,
DecodingError.Context(codingPath: self.codingPath,
debugDescription: "Cannot get UnkeyedDecodingContainer -- no value found for key \(_errorDescription(of: key))"))
Expand All @@ -1548,7 +1576,7 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
self.decoder.codingPath.append(key)
defer { self.decoder.codingPath.removeLast() }

let value: Any = self.container[key.stringValue] ?? NSNull()
let value: Any = self.container[_converted(key).stringValue] ?? NSNull()
return _JSONDecoder(referencing: value, at: self.decoder.codingPath, options: self.decoder.options)
}

Expand Down
40 changes: 33 additions & 7 deletions test/stdlib/TestJSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -410,11 +410,22 @@ class TestJSONEncoder : TestJSONEncoderSuper {
}

// MARK: - Key Strategy Tests
private struct EncodeMe : Encodable {
private struct EncodeMe : Codable, Equatable {
var keyName: String
let found: Bool
init(keyName: String) {
self.keyName = keyName
found = false
}
init(from coder: Decoder) throws {
let c = try coder.container(keyedBy: _TestKey.self)
keyName = try c.decode(String.self, forKey: _TestKey(stringValue: "__camel_case_key")!)
found = try c.decode(Bool.self, forKey: _TestKey(stringValue: keyName)!)
}
func encode(to coder: Encoder) throws {
var c = coder.container(keyedBy: _TestKey.self)
try c.encode("test", forKey: _TestKey(stringValue: keyName)!)
try c.encode(keyName, forKey: _TestKey(stringValue: "__camelCaseKey")!)
try c.encode(true, forKey: _TestKey(stringValue: keyName)!)
}
}

Expand Down Expand Up @@ -452,7 +463,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
]

for test in toSnakeCaseTests {
let expected = "{\"\(test.1)\":\"test\"}"
let expected = "{\"__camel_case_key\":\"\(test.0)\",\"\(test.1)\":true}"
let encoded = EncodeMe(keyName: test.0)

let encoder = JSONEncoder()
Expand All @@ -461,11 +472,17 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let resultString = String(bytes: resultData, encoding: .utf8)

expectEqual(expected, resultString)

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .useSnakeCasedKeys
let decoded = try! decoder.decode(EncodeMe.self, from: resultData)

expectTrue(decoded.found)
}
}

func testEncodingKeyStrategyCustom() {
let expected = "{\"QQQhello\":\"test\"}"
let expected = "{\"QQQ__camelCaseKey\":\"hello\",\"QQQhello\":true}"
let encoded = EncodeMe(keyName: "hello")

let encoder = JSONEncoder()
Expand All @@ -491,11 +508,12 @@ class TestJSONEncoder : TestJSONEncoderSuper {
func testEncodingKeyStrategyPath() {
// Make sure a more complex path shows up the way we want
// Make sure the path reflects keys in the Swift, not the resulting ones in the JSON
let expected = "{\"QQQouterValue\":{\"QQQnestedValue\":{\"QQQhelloWorld\":\"test\"}}}"
let expected = "{\"QQQouterValue\":{\"QQQnestedValue\":{\"QQQ__camelCaseKey\":\"helloWorld\",\"QQQhelloWorld\":true}}}"
let encoded = EncodeNestedNested(outerValue: EncodeNested(nestedValue: EncodeMe(keyName: "helloWorld")))

let encoder = JSONEncoder()
var callCount = 0
var callCountOfNested = 0

let customKeyConversion = { (_ path : [CodingKey]) -> CodingKey in
// This should be called three times:
Expand All @@ -511,7 +529,15 @@ class TestJSONEncoder : TestJSONEncoderSuper {
} else if path.count == 2 {
expectEqual(["outerValue", "nestedValue"], path.map { $0.stringValue })
} else if path.count == 3 {
expectEqual(["outerValue", "nestedValue", "helloWorld"], path.map { $0.stringValue })
switch callCountOfNested {
case 0:
expectEqual(["outerValue", "nestedValue", "__camelCaseKey"], path.map { $0.stringValue })
case 1:
expectEqual(["outerValue", "nestedValue", "helloWorld"], path.map { $0.stringValue })
default:
expectUnreachable("The path mysteriously had more entries")
}
callCountOfNested = callCountOfNested + 1
} else {
expectUnreachable("The path mysteriously had more entries")
}
Expand All @@ -524,7 +550,7 @@ class TestJSONEncoder : TestJSONEncoderSuper {
let resultString = String(bytes: resultData, encoding: .utf8)

expectEqual(expected, resultString)
expectEqual(3, callCount)
expectEqual(4, callCount)
}

private struct DecodeMe : Decodable {
Expand Down