diff --git a/README.md b/README.md index 74adc12..fd49c91 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,27 @@ decoder.intDecodingStrategy = .clamping(roundingRule: .toNearestOrAwayFromZero) let values = try decoder.decode([Int8].self, from: [10, 20.5, 1000, -Double.infinity]) ``` +## Key Strategy + +Keys can be encoded to snake_case by setting the strategy: + +```swift +var encoder = KeyValueEncoder() +encoder.keyEncodingStrategy = .convertToSnakeCase + +// ["first_name": "fish", "surname": "chips"] +let dict = try encoder.encode(Person(firstName: "fish", surname: "chips)) +``` + +And decoded from snake_case: + +```swift +var decoder = KeyValueDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let person = try decoder.decode(Person.self, from: dict) +``` + ## UserDefaults Encode and decode [`Codable`](https://developer.apple.com/documentation/swift/codable) types with [`UserDefaults`](https://developer.apple.com/documentation/foundation/userdefaults): diff --git a/Sources/KeyValueDecoder.swift b/Sources/KeyValueDecoder.swift index 5fde717..a60f9b6 100644 --- a/Sources/KeyValueDecoder.swift +++ b/Sources/KeyValueDecoder.swift @@ -44,6 +44,9 @@ public struct KeyValueDecoder: Sendable { /// The strategy to use for decoding BinaryInteger types. Defaults to `.exact` for lossless conversion between types. public var intDecodingStrategy: IntDecodingStrategy = .exact + /// The strategy to use for decoding each types keys. + public var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys + /// Initializes `self` with default strategy. public init () { self.userInfo = [:] @@ -82,6 +85,15 @@ public struct KeyValueDecoder: Sendable { /// Floating point conversions are also clamped, rounded when a rule is provided case clamping(roundingRule: FloatingPointRoundingRule?) } + + /// Strategy to determine how to decode a type’s coding keys from String values. + public enum KeyDecodingStrategy: Sendable { + /// A key decoding strategy that converts snake-case keys to camel-case keys. + case convertFromSnakeCase + + /// A key encoding strategy that doesn’t change key names during encoding. + case useDefaultKeys + } } #if canImport(Combine) @@ -105,12 +117,14 @@ private extension KeyValueDecoder { struct DecodingStrategy { var optionals: NilDecodingStrategy var integers: IntDecodingStrategy + var keys: KeyDecodingStrategy } var strategy: DecodingStrategy { DecodingStrategy( optionals: nilDecodingStrategy, - integers: intDecodingStrategy + integers: intDecodingStrategy, + keys: keyDecodingStrategy ) } @@ -381,7 +395,8 @@ private extension KeyValueDecoder { func container(for key: Key) throws -> SingleContainer { let path = codingPath.appending(key: key) - guard let value = storage[key.stringValue] else { + let kkk = strategy.keys.makeStorageKey(for: key.stringValue) + guard let value = storage[kkk] else { let keyPath = codingPath.makeKeyPath(appending: key) let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Dictionary does not contain key \(keyPath)") throw DecodingError.keyNotFound(key, context) @@ -395,7 +410,7 @@ private extension KeyValueDecoder { } func contains(_ key: Key) -> Bool { - return storage[key.stringValue] != nil + return storage[strategy.keys.makeStorageKey(for: key.stringValue)] != nil } func decodeNil(forKey key: Key) throws -> Bool { @@ -640,6 +655,16 @@ private extension KeyValueDecoder { } } +extension KeyValueDecoder.KeyDecodingStrategy { + + func makeStorageKey(for key: String) -> String { + switch self { + case .useDefaultKeys: return key + case .convertFromSnakeCase: return key.toSnakeCase() + } + } +} + extension BinaryInteger { init?(from source: Double, using strategy: KeyValueDecoder.IntDecodingStrategy) { diff --git a/Sources/KeyValueEncoder.swift b/Sources/KeyValueEncoder.swift index 23f35d5..c72a12c 100644 --- a/Sources/KeyValueEncoder.swift +++ b/Sources/KeyValueEncoder.swift @@ -40,6 +40,9 @@ public struct KeyValueEncoder: Sendable { /// The strategy to use for encoding `nil`. Defaults to `Optional.none` which can be cast to any optional type. public var nilEncodingStrategy: NilEncodingStrategy = .default + /// The strategy to use for encoding each types keys. + public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys + /// Initializes `self` with default strategies. public init () { self.userInfo = [:] @@ -55,6 +58,15 @@ public struct KeyValueEncoder: Sendable { /// Strategy used to encode nil values. public typealias NilEncodingStrategy = NilCodingStrategy + + /// Strategy to determine how to encode a type’s coding keys as String values. + public enum KeyEncodingStrategy: Sendable { + /// A key encoding strategy that converts camel-case keys to snake-case keys. + case convertToSnakeCase + + /// A key encoding strategy that doesn’t change key names during encoding. + case useDefaultKeys + } } /// Strategy used to encode and decode nil values. @@ -108,7 +120,7 @@ extension KeyValueEncoder { } func encodeValue(_ value: T) throws -> EncodedValue { - try Encoder(userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy).encodeToValue(value) + return try Encoder(userInfo: userInfo, strategy: strategy).encodeToValue(value) } } @@ -149,16 +161,28 @@ private extension KeyValueEncoder.NilEncodingStrategy { private extension KeyValueEncoder { + struct EncodingStrategy { + var optionals: NilEncodingStrategy + var keys: KeyEncodingStrategy + } + + var strategy: EncodingStrategy { + EncodingStrategy( + optionals: nilEncodingStrategy, + keys: keyEncodingStrategy + ) + } + final class Encoder: Swift.Encoder { let codingPath: [any CodingKey] let userInfo: [CodingUserInfoKey: Any] - let nilEncodingStrategy: NilEncodingStrategy + let strategy: EncodingStrategy - init(codingPath: [any CodingKey] = [], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) { + init(codingPath: [any CodingKey] = [], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) { self.codingPath = codingPath self.userInfo = userInfo - self.nilEncodingStrategy = nilEncodingStrategy + self.strategy = strategy } private(set) var container: EncodedValue? { @@ -175,19 +199,19 @@ private extension KeyValueEncoder { } func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { - let keyed = KeyedContainer(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let keyed = KeyedContainer(codingPath: codingPath, userInfo: userInfo, strategy: strategy) container = .provider(keyed.getEncodedValue) return KeyedEncodingContainer(keyed) } func unkeyedContainer() -> any UnkeyedEncodingContainer { - let unkeyed = UnkeyedContainer(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let unkeyed = UnkeyedContainer(codingPath: codingPath, userInfo: userInfo, strategy: strategy) container = .provider(unkeyed.getEncodedValue) return unkeyed } func singleValueContainer() -> any SingleValueEncodingContainer { - let single = SingleContainer(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let single = SingleContainer(codingPath: codingPath, userInfo: userInfo, strategy: strategy) container = .provider(single.getEncodedValue) return single } @@ -209,27 +233,27 @@ private extension KeyValueEncoder { let codingPath: [any CodingKey] private let userInfo: [CodingUserInfoKey: Any] - private let nilEncodingStrategy: NilEncodingStrategy + private let strategy: EncodingStrategy - init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) { + init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) { self.codingPath = codingPath self.storage = [:] self.userInfo = userInfo - self.nilEncodingStrategy = nilEncodingStrategy + self.strategy = strategy } private var storage: [String: EncodedValue] func setValue(_ value: Any, forKey key: Key) { - storage[key.stringValue] = .value(value) + storage[strategy.keys.makeStorageKey(for: key.stringValue)] = .value(value) } func setValue(_ value: EncodedValue, forKey key: Key) { - storage[key.stringValue] = value + storage[strategy.keys.makeStorageKey(for: key.stringValue)] = value } func getEncodedValue() throws -> EncodedValue { - try .value(storage.compactMapValues { try $0.getValue(strategy: nilEncodingStrategy) }) + try .value(storage.compactMapValues { try $0.getValue(strategy: strategy.optionals) }) } func encodeNil(forKey key: Key) { @@ -298,8 +322,8 @@ private extension KeyValueEncoder { return } - let encoder = Encoder(codingPath: codingPath.appending(key: key), userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) - if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) { + let encoder = Encoder(codingPath: codingPath.appending(key: key), userInfo: userInfo, strategy: strategy) + if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) { setValue(value, forKey: key) } else { setValue(.null, forKey: key) @@ -308,14 +332,14 @@ private extension KeyValueEncoder { func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { let path = codingPath.appending(key: key) - let keyed = KeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let keyed = KeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy) storage[key.stringValue] = .provider(keyed.getEncodedValue) return KeyedEncodingContainer(keyed) } func nestedUnkeyedContainer(forKey key: K) -> any UnkeyedEncodingContainer { let path = codingPath.appending(key: key) - let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy) storage[key.stringValue] = .provider(unkeyed.getEncodedValue) return unkeyed } @@ -326,7 +350,7 @@ private extension KeyValueEncoder { func superEncoder(forKey key: Key) -> any Swift.Encoder { let path = codingPath.appending(key: key) - let encoder = Encoder(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let encoder = Encoder(codingPath: path, userInfo: userInfo, strategy: strategy) storage[key.stringValue] = .provider(encoder.getEncodedValue) return encoder } @@ -336,18 +360,18 @@ private extension KeyValueEncoder { let codingPath: [any CodingKey] private let userInfo: [CodingUserInfoKey: Any] - private let nilEncodingStrategy: NilEncodingStrategy + private let strategy: EncodingStrategy - init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) { + init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) { self.codingPath = codingPath self.userInfo = userInfo - self.nilEncodingStrategy = nilEncodingStrategy + self.strategy = strategy } private var storage: [EncodedValue] = [] func getEncodedValue() throws -> EncodedValue { - return try .value(storage.compactMap { try $0.getValue(strategy: nilEncodingStrategy) }) + return try .value(storage.compactMap { try $0.getValue(strategy: strategy.optionals) }) } public var count: Int { @@ -431,9 +455,9 @@ private extension KeyValueEncoder { let encoder = Encoder( codingPath: codingPath.appending(index: count), userInfo: userInfo, - nilEncodingStrategy: nilEncodingStrategy + strategy: strategy ) - if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) { + if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) { appendValue(value) } else { appendValue(.null) @@ -442,21 +466,21 @@ private extension KeyValueEncoder { func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer { let path = codingPath.appending(index: count) - let keyed = KeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let keyed = KeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy) storage.append(.provider(keyed.getEncodedValue)) return KeyedEncodingContainer(keyed) } func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { let path = codingPath.appending(index: count) - let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy) storage.append(.provider(unkeyed.getEncodedValue)) return unkeyed } func superEncoder() -> any Swift.Encoder { let path = codingPath.appending(index: count) - let encoder = Encoder(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) + let encoder = Encoder(codingPath: path, userInfo: userInfo, strategy: strategy) storage.append(.provider(encoder.getEncodedValue)) return encoder } @@ -466,12 +490,12 @@ private extension KeyValueEncoder { let codingPath: [any CodingKey] private let userInfo: [CodingUserInfoKey: Any] - private let nilEncodingStrategy: NilEncodingStrategy + private let strategy: EncodingStrategy - init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) { + init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) { self.codingPath = codingPath self.userInfo = userInfo - self.nilEncodingStrategy = nilEncodingStrategy + self.strategy = strategy } private var value: EncodedValue? @@ -547,8 +571,8 @@ private extension KeyValueEncoder { return } - let encoder = Encoder(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy) - if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) { + let encoder = Encoder(codingPath: codingPath, userInfo: userInfo, strategy: strategy) + if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) { self.value = .value(value) } else { self.value = .null @@ -557,6 +581,59 @@ private extension KeyValueEncoder { } } +extension KeyValueEncoder.KeyEncodingStrategy { + + func makeStorageKey(for key: String) -> String { + switch self { + case .useDefaultKeys: return key + case .convertToSnakeCase: return key.toSnakeCase() + } + } +} + +extension String { + + func toSnakeCase() -> String { + camelCaseWords + .map { $0.lowercased() } + .joined(separator: "_") + } + + var camelCaseWords: [Substring] { + var words: [Range] = [] + var wordStart = startIndex + var searchRange = index(after: wordStart).. [any CodingKey] { diff --git a/Tests/KeyValueDecoderTests.swift b/Tests/KeyValueDecoderTests.swift index 9f1bd7d..088e4c9 100644 --- a/Tests/KeyValueDecoderTests.swift +++ b/Tests/KeyValueDecoderTests.swift @@ -448,6 +448,28 @@ struct KeyValueDecoderTests { } } + @Test + func decodes_SnakeCase() throws { + let dict: [String: Any] = [ + "first_name": "fish", + "surname": "chips", + "profile_url": "drop", + "rel_nodes_link": ["ocean": ["first_name": "shrimp", "surname": "anemone"]] + ] + + var decoder = KeyValueDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + #expect( + try decoder.decode(SnakeNode.self, from: dict) == SnakeNode( + firstName: "fish", + lastName: "chips", + profileURL: "drop", + relNODESLink: ["ocean": SnakeNode(firstName: "shrimp", lastName: "anemone")] + ) + ) + } + @Test func decodes_NestedUnkeyed() throws { let decoder = KeyValueDecoder() diff --git a/Tests/KeyValueEncoderTests.swift b/Tests/KeyValueEncoderTests.swift index 1b500b5..d0ba418 100644 --- a/Tests/KeyValueEncoderTests.swift +++ b/Tests/KeyValueEncoderTests.swift @@ -365,6 +365,24 @@ struct KeyValueEncodedTests { ) } + @Test + func keyedContainer_Encodes_SnakeCase() throws { + let shrimp = SnakeNode(firstName: "shrimp", lastName: "anemone") + let node = SnakeNode(firstName: "fish", lastName: "chips", profileURL: "drop", relNODESLink: ["ocean": shrimp]) + + var encoder = KeyValueEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + + #expect( + try encoder.encode(node) as? NSDictionary == [ + "first_name": "fish", + "surname": "chips", + "profile_url": "drop", + "rel_nodes_link": ["ocean": ["first_name": "shrimp", "surname": "anemone"]] + ] + ) + } + @Test func unkeyedContainer_Encodes_Optionals() throws { #expect( @@ -747,6 +765,18 @@ struct Node: Codable, Equatable { } } +struct SnakeNode: Codable, Equatable { + var firstName: String + var lastName: String + var profileURL: String? + var relNODESLink: [String: SnakeNode]? + + enum CodingKeys: String, CodingKey { + case firstName, profileURL, relNODESLink + case lastName = "surname" + } +} + extension KeyValueEncoder.EncodedValue: Swift.Equatable { public static func == (lhs: Self, rhs: Self) -> Bool {