From f3e4fe5463844a8a30005853bff407010fa72bd1 Mon Sep 17 00:00:00 2001 From: Abe White Date: Mon, 30 Sep 2024 11:24:22 -0500 Subject: [PATCH] Support snake case conversions in JSONDecoder/Encoder --- Package.swift | 2 +- README.md | 22 +--- Sources/SkipFoundation/JSONDecoder.swift | 47 +++++++- Sources/SkipFoundation/JSONEncoder.swift | 104 ++++++++++-------- .../Foundation/JSONTests.swift | 41 ++++++- 5 files changed, 143 insertions(+), 73 deletions(-) diff --git a/Package.swift b/Package.swift index ccd22ec..e35edd8 100644 --- a/Package.swift +++ b/Package.swift @@ -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")]), diff --git a/README.md b/README.md index dd04776..e99b8dc 100644 --- a/README.md +++ b/README.md @@ -416,26 +416,12 @@ Support levels: - 🟢 - -
- JSONDecoder - -
- + ✅ + JSONDecoder - 🟢 - -
- JSONEncoder - -
- + ✅ + JSONEncoder ✅ diff --git a/Sources/SkipFoundation/JSONDecoder.swift b/Sources/SkipFoundation/JSONDecoder.swift index c733be7..c6ee3f1 100644 --- a/Sources/SkipFoundation/JSONDecoder.swift +++ b/Sources/SkipFoundation/JSONDecoder.swift @@ -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..: KeyedDecodingCon init(keyedBy: Any.Type, impl: JSONDecoderImpl, codingPath: [CodingKey], dictionary: Dictionary) { self.impl = impl self.codingPath = codingPath - let decodeKeys = keyedBy == DictionaryCodingKey.self + let decodeKeys = keyedBy != DictionaryCodingKey.self if decodeKeys { switch impl.options.keyDecodingStrategy { case .useDefaultKeys: diff --git a/Sources/SkipFoundation/JSONEncoder.swift b/Sources/SkipFoundation/JSONEncoder.swift index 5e17881..cb096e5 100644 --- a/Sources/SkipFoundation/JSONEncoder.swift +++ b/Sources/SkipFoundation/JSONEncoder.swift @@ -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] = [] -// // 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)..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..] = [] + // 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..1 capital letters. Turn those into a word, stopping at the capital before the lower case character. + let beforeLowerIndex = lowerCaseIndex - 1 + words.append(upperCaseIndex..: 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 { diff --git a/Tests/SkipFoundationTests/Foundation/JSONTests.swift b/Tests/SkipFoundationTests/Foundation/JSONTests.swift index 38fc8c4..6cb2dd8 100644 --- a/Tests/SkipFoundationTests/Foundation/JSONTests.swift +++ b/Tests/SkipFoundationTests/Foundation/JSONTests.swift @@ -225,10 +225,13 @@ class TestJSON : XCTestCase { } /// Round-trip a type - @inline(__always) private func roundtrip(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(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) @@ -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, @@ -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("""