Skip to content

Commit

Permalink
Support snake case conversions in JSONDecoder/Encoder
Browse files Browse the repository at this point in the history
  • Loading branch information
aabewhite committed Sep 30, 2024
1 parent bf6b564 commit f3e4fe5
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 73 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.0.10"),
.package(url: "https://source.skip.tools/skip-lib.git", from: "1.1.0"),
.package(url: "https://source.skip.tools/skip-lib.git", from: "1.1.1"),
],
targets: [
.target(name: "SkipFoundation", dependencies: [.product(name: "SkipLib", package: "skip-lib")], plugins: [.plugin(name: "skipstone", package: "skip")]),
Expand Down
22 changes: 4 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,26 +416,12 @@ Support levels:
</td>
</tr>
<tr>
<td>🟢</td>
<td>
<details>
<summary><code>JSONDecoder</code></summary>
<ul>
<li>Snake case key decoding is not supported</li>
</ul>
</details>
</td>
<td>✅</td>
<td><code>JSONDecoder</code></td>
</tr>
<tr>
<td>🟢</td>
<td>
<details>
<summary><code>JSONEncoder</code></summary>
<ul>
<li>Snake case key encoding is not supported</li>
</ul>
</details>
</td>
<td>✅</td>
<td><code>JSONEncoder</code></td>
</tr>
<tr>
<td>✅</td>
Expand Down
47 changes: 44 additions & 3 deletions Sources/SkipFoundation/JSONDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,49 @@ open class JSONDecoder {
case convertFromSnakeCase
case custom((_ codingPath: [CodingKey]) -> CodingKey)

fileprivate static func _convertFromSnakeCase(_ key: String) -> String {
fatalError("SKIP TODO: JSON snakeCase")
fileprivate static func _convertFromSnakeCase(_ stringKey: String) -> String {
guard !stringKey.isEmpty else { return stringKey }

// Find the first non-underscore character
guard let firstNonUnderscore = stringKey.firstIndex(where: { $0 != "_" }) else {
// Reached the end without finding an _
return stringKey
}

// Find the last non-underscore character
var lastNonUnderscore = stringKey.count - 1
while lastNonUnderscore > firstNonUnderscore && stringKey[lastNonUnderscore] == "_" {
lastNonUnderscore -= 1
}

let keyRange = firstNonUnderscore...lastNonUnderscore
let leadingUnderscoreRange = 0..<firstNonUnderscore
let trailingUnderscoreRange = (lastNonUnderscore + 1)..<stringKey.count

let components = stringKey[keyRange].split(separator: "_")
let joinedString: String
if components.count == 1 {
// No underscores in key, leave the word as is - maybe already camel cased
joinedString = String(stringKey[keyRange])
} else {
joinedString = ([components[0].lowercased()] + components[1...].map { $0.capitalized }).joined()
}

// Do a cheap isEmpty check before creating and appending potentially empty strings
let result: String
if (leadingUnderscoreRange.isEmpty && trailingUnderscoreRange.isEmpty) {
result = joinedString
} else if (!leadingUnderscoreRange.isEmpty && !trailingUnderscoreRange.isEmpty) {
// Both leading and trailing underscores
result = String(stringKey[leadingUnderscoreRange]) + joinedString + String(stringKey[trailingUnderscoreRange])
} else if (!leadingUnderscoreRange.isEmpty) {
// Just leading
result = String(stringKey[leadingUnderscoreRange]) + joinedString
} else {
// Just trailing
result = joinedString + String(stringKey[trailingUnderscoreRange])
}
return result
}
}

Expand Down Expand Up @@ -373,7 +414,7 @@ fileprivate struct JSONKeyedDecodingContainer<Key : CodingKey>: KeyedDecodingCon
init(keyedBy: Any.Type, impl: JSONDecoderImpl, codingPath: [CodingKey], dictionary: Dictionary<String, JSONDecoderValue>) {
self.impl = impl
self.codingPath = codingPath
let decodeKeys = keyedBy == DictionaryCodingKey.self
let decodeKeys = keyedBy != DictionaryCodingKey.self
if decodeKeys {
switch impl.options.keyDecodingStrategy {
case .useDefaultKeys:
Expand Down
104 changes: 57 additions & 47 deletions Sources/SkipFoundation/JSONEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,51 +62,61 @@ open class JSONEncoder {

fileprivate static func _convertToSnakeCase(_ stringKey: String) -> String {
guard !stringKey.isEmpty else { return stringKey }
fatalError("SKIP TODO: JSON snakeCase")
// var words: [Range<String.Index>] = []
// // The general idea of this algorithm is to split words on transition from lower to upper case, then on transition of >1 upper case characters to lowercase
// //
// // myProperty -> my_property
// // myURLProperty -> my_url_property
// //
// // We assume, per Swift naming conventions, that the first character of the key is lowercase.
// var wordStart = stringKey.startIndex
// var searchRange = stringKey.index(after: wordStart)..<stringKey.endIndex
//
// // Find next uppercase character
// while let upperCaseRange = stringKey.rangeOfCharacter(from: .uppercaseLetters, options: [], range: searchRange) {
// let untilUpperCase = wordStart..<upperCaseRange.lowerBound
// words.append(untilUpperCase)
//
// // Find next lowercase character
// searchRange = upperCaseRange.lowerBound..<searchRange.upperBound
// guard let lowerCaseRange = stringKey.rangeOfCharacter(from: .lowercaseLetters, options: [], range: searchRange) else {
// // There are no more lower case letters. Just end here.
// wordStart = searchRange.lowerBound
// break
// }
//
// // Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase letters that we should treat as its own word
// let nextCharacterAfterCapital = stringKey.index(after: upperCaseRange.lowerBound)
// if lowerCaseRange.lowerBound == nextCharacterAfterCapital {
// // The next character after capital is a lower case character and therefore not a word boundary.
// // Continue searching for the next upper case for the boundary.
// wordStart = upperCaseRange.lowerBound
// } else {
// // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character.
// let beforeLowerIndex = stringKey.index(before: lowerCaseRange.lowerBound)
// words.append(upperCaseRange.lowerBound..<beforeLowerIndex)
//
// // Next word starts at the capital before the lowercase we just found
// wordStart = beforeLowerIndex
// }
// searchRange = lowerCaseRange.upperBound..<searchRange.upperBound
// }
// words.append(wordStart..<searchRange.upperBound)
// let result = words.map({ (range) in
// return stringKey[range].lowercased()
// }).joined(separator: "_")
// return result
var words: [Range<Int>] = []
// The general idea of this algorithm is to split words on transition from lower to upper case, then on transition of >1 upper case characters to lowercase
//
// myProperty -> my_property
// myURLProperty -> my_url_property
//
// We assume, per Swift naming conventions, that the first character of the key is lowercase.
var wordStart = 0
var searchStart = 1
var searchEnd = stringKey.count

func indexOfCharacterCase(upper: Bool, in string: String, searchStart: Int, searchEnd: Int) -> Int? {
for i in searchStart..<searchEnd {
let c = string[i]
if (upper && c.isUppercase) || (!upper && !c.isUppercase) {
return i
}
}
return nil
}

// Find next uppercase character
while let upperCaseIndex = indexOfCharacterCase(upper: true, in: stringKey, searchStart: searchStart, searchEnd: searchEnd) {
let untilUpperCase = wordStart..<upperCaseIndex
words.append(untilUpperCase)

// Find next lowercase character
searchStart = upperCaseIndex
guard let lowerCaseIndex = indexOfCharacterCase(upper: false, in: stringKey, searchStart: searchStart, searchEnd: searchEnd) else {
// There are no more lower case letters. Just end here.
wordStart = searchStart
break
}

// Is the next lowercase letter more than 1 after the uppercase? If so, we encountered a group of uppercase letters that we should treat as its own word
let nextCharacterAfterCapital = searchStart + 1
if lowerCaseIndex == nextCharacterAfterCapital {
// The next character after capital is a lower case character and therefore not a word boundary.
// Continue searching for the next upper case for the boundary.
wordStart = upperCaseIndex
} else {
// There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character.
let beforeLowerIndex = lowerCaseIndex - 1
words.append(upperCaseIndex..<beforeLowerIndex)

// Next word starts at the capital before the lowercase we just found
wordStart = beforeLowerIndex
}
searchStart = lowerCaseIndex
}
words.append(wordStart..<searchEnd)
let result = words.map { range in
return stringKey[range].lowercased()
}.joined(separator: "_")
return result
}
}

Expand Down Expand Up @@ -455,14 +465,14 @@ private struct JSONKeyedEncodingContainer<Key: CodingKey>: KeyedEncodingContaine
self.impl = impl
self.object = impl.object!
self.codingPath = codingPath
self.encodeKeys = keyedBy == DictionaryCodingKey.self
self.encodeKeys = keyedBy != DictionaryCodingKey.self
}

init(keyedBy: Any.Type, impl: JSONEncoderImpl, object: JSONFuture.RefObject, codingPath: [CodingKey]) {
self.impl = impl
self.object = object
self.codingPath = codingPath
self.encodeKeys = keyedBy == DictionaryCodingKey.self
self.encodeKeys = keyedBy != DictionaryCodingKey.self
}

private func _converted(_ key: JSONEncoderKey) -> CodingKey {
Expand Down
41 changes: 37 additions & 4 deletions Tests/SkipFoundationTests/Foundation/JSONTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,13 @@ class TestJSON : XCTestCase {
}

/// Round-trip a type
@inline(__always) private func roundtrip<T>(value: T, fmt: JSONEncoder.OutputFormatting? = .sortedKeys, data: JSONEncoder.DataEncodingStrategy? = nil, date: JSONEncoder.DateEncodingStrategy? = nil, floats: JSONEncoder.NonConformingFloatEncodingStrategy? = nil, keys: JSONEncoder.KeyEncodingStrategy? = nil) throws -> String where T : Encodable, T : Decodable, T : Equatable {
@inline(__always) private func roundtrip<T>(value: T, fmt: JSONEncoder.OutputFormatting? = .sortedKeys, data: JSONEncoder.DataEncodingStrategy? = nil, date: JSONEncoder.DateEncodingStrategy? = nil, floats: JSONEncoder.NonConformingFloatEncodingStrategy? = nil, keys: JSONEncoder.KeyEncodingStrategy? = nil, dkeys: JSONDecoder.KeyDecodingStrategy? = nil) throws -> String where T : Encodable, T : Decodable, T : Equatable {
let json = try enc(value, fmt: fmt, data: data, date: date, floats: floats, keys: keys)
let decoder = JSONDecoder()

let decoder = JSONDecoder()
if let dkeys {
decoder.keyDecodingStrategy = dkeys
}
let value2 = try decoder.decode(T.self, from: json.data(using: String.Encoding.utf8)!)
XCTAssertEqual(value, value2)

Expand Down Expand Up @@ -341,8 +344,37 @@ class TestJSON : XCTestCase {
}
""", try roundtrip(value: testData, fmt: [.prettyPrinted, .sortedKeys] as JSONEncoder.OutputFormatting))
#endif

#if !SKIP

#if SKIP
XCTAssertEqual("""
{
"this_is_a_bool" : true,
"this_is_a_date" : 12345,
"this_is_a_dictionary" : {
"X" : true,
"Y" : false
},
"this_is_a_double" : 12,
"this_is_a_float" : 11,
"this_is_a_string" : "ABC",
"this_is_a_uint" : 6,
"this_is_a_uint16" : 8,
"this_is_a_uint32" : 9,
"this_is_a_uint64" : 10,
"this_is_a_uint8" : 7,
"this_is_an_array" : [
-1,
0,
1
],
"this_is_an_int" : 1,
"this_is_an_int16" : 3,
"this_is_an_int32" : 4,
"this_is_an_int64" : 5,
"this_is_an_int8" : 2
}
""", try enc(testData, fmt: [.prettyPrinted, .sortedKeys] as JSONEncoder.OutputFormatting, keys: .convertToSnakeCase as JSONEncoder.KeyEncodingStrategy))
#else
XCTAssertEqual("""
{
"this_is_a_bool" : true,
Expand Down Expand Up @@ -387,6 +419,7 @@ class TestJSON : XCTestCase {
"lastName" : "Doe"
}
""", try roundtrip(value: p1, fmt: [.prettyPrinted, .sortedKeys] as JSONEncoder.OutputFormatting))
XCTAssertEqual(#"{"first_name":"Jon","height":180.5,"last_name":"Doe"}"#, try roundtrip(value: p1, keys: JSONEncoder.KeyEncodingStrategy.convertToSnakeCase, dkeys: JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase))

let org = Org(head: p2, people: [p1, p3], departmentHeads: ["X":p2, "Y": p3], departmentMembers: ["Y":[p1], "X": [p2, p1]])
XCTAssertEqual("""
Expand Down

0 comments on commit f3e4fe5

Please sign in to comment.