Skip to content

Commit a5e7923

Browse files
committed
KeyEncodingStrategy
1 parent 900eaff commit a5e7923

File tree

2 files changed

+139
-32
lines changed

2 files changed

+139
-32
lines changed

Sources/KeyValueEncoder.swift

Lines changed: 109 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public struct KeyValueEncoder: Sendable {
4040
/// The strategy to use for encoding `nil`. Defaults to `Optional<Any>.none` which can be cast to any optional type.
4141
public var nilEncodingStrategy: NilEncodingStrategy = .default
4242

43+
/// The strategy to use for encoding each types keys.
44+
public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys
45+
4346
/// Initializes `self` with default strategies.
4447
public init () {
4548
self.userInfo = [:]
@@ -55,6 +58,15 @@ public struct KeyValueEncoder: Sendable {
5558

5659
/// Strategy used to encode nil values.
5760
public typealias NilEncodingStrategy = NilCodingStrategy
61+
62+
/// Strategy to determine how to encode a type’s coding keys as String values.
63+
public enum KeyEncodingStrategy: Sendable {
64+
/// A key encoding strategy that converts camel-case keys to snake-case keys.
65+
case convertToSnakeCase
66+
67+
/// A key encoding strategy that doesn’t change key names during encoding.
68+
case useDefaultKeys
69+
}
5870
}
5971

6072
/// Strategy used to encode and decode nil values.
@@ -108,7 +120,7 @@ extension KeyValueEncoder {
108120
}
109121

110122
func encodeValue<T: Encodable>(_ value: T) throws -> EncodedValue {
111-
try Encoder(userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy).encodeToValue(value)
123+
return try Encoder(userInfo: userInfo, strategy: strategy).encodeToValue(value)
112124
}
113125
}
114126

@@ -149,16 +161,28 @@ private extension KeyValueEncoder.NilEncodingStrategy {
149161

150162
private extension KeyValueEncoder {
151163

164+
struct EncodingStrategy {
165+
var optionals: NilEncodingStrategy
166+
var keys: KeyEncodingStrategy
167+
}
168+
169+
var strategy: EncodingStrategy {
170+
EncodingStrategy(
171+
optionals: nilEncodingStrategy,
172+
keys: keyEncodingStrategy
173+
)
174+
}
175+
152176
final class Encoder: Swift.Encoder {
153177

154178
let codingPath: [any CodingKey]
155179
let userInfo: [CodingUserInfoKey: Any]
156-
let nilEncodingStrategy: NilEncodingStrategy
180+
let strategy: EncodingStrategy
157181

158-
init(codingPath: [any CodingKey] = [], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) {
182+
init(codingPath: [any CodingKey] = [], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) {
159183
self.codingPath = codingPath
160184
self.userInfo = userInfo
161-
self.nilEncodingStrategy = nilEncodingStrategy
185+
self.strategy = strategy
162186
}
163187

164188
private(set) var container: EncodedValue? {
@@ -175,19 +199,19 @@ private extension KeyValueEncoder {
175199
}
176200

177201
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key: CodingKey {
178-
let keyed = KeyedContainer<Key>(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
202+
let keyed = KeyedContainer<Key>(codingPath: codingPath, userInfo: userInfo, strategy: strategy)
179203
container = .provider(keyed.getEncodedValue)
180204
return KeyedEncodingContainer(keyed)
181205
}
182206

183207
func unkeyedContainer() -> any UnkeyedEncodingContainer {
184-
let unkeyed = UnkeyedContainer(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
208+
let unkeyed = UnkeyedContainer(codingPath: codingPath, userInfo: userInfo, strategy: strategy)
185209
container = .provider(unkeyed.getEncodedValue)
186210
return unkeyed
187211
}
188212

189213
func singleValueContainer() -> any SingleValueEncodingContainer {
190-
let single = SingleContainer(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
214+
let single = SingleContainer(codingPath: codingPath, userInfo: userInfo, strategy: strategy)
191215
container = .provider(single.getEncodedValue)
192216
return single
193217
}
@@ -209,27 +233,27 @@ private extension KeyValueEncoder {
209233

210234
let codingPath: [any CodingKey]
211235
private let userInfo: [CodingUserInfoKey: Any]
212-
private let nilEncodingStrategy: NilEncodingStrategy
236+
private let strategy: EncodingStrategy
213237

214-
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) {
238+
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) {
215239
self.codingPath = codingPath
216240
self.storage = [:]
217241
self.userInfo = userInfo
218-
self.nilEncodingStrategy = nilEncodingStrategy
242+
self.strategy = strategy
219243
}
220244

221245
private var storage: [String: EncodedValue]
222246

223247
func setValue(_ value: Any, forKey key: Key) {
224-
storage[key.stringValue] = .value(value)
248+
storage[strategy.keys.makeStorageKey(for: key.stringValue)] = .value(value)
225249
}
226250

227251
func setValue(_ value: EncodedValue, forKey key: Key) {
228-
storage[key.stringValue] = value
252+
storage[strategy.keys.makeStorageKey(for: key.stringValue)] = value
229253
}
230254

231255
func getEncodedValue() throws -> EncodedValue {
232-
try .value(storage.compactMapValues { try $0.getValue(strategy: nilEncodingStrategy) })
256+
try .value(storage.compactMapValues { try $0.getValue(strategy: strategy.optionals) })
233257
}
234258

235259
func encodeNil(forKey key: Key) {
@@ -298,8 +322,8 @@ private extension KeyValueEncoder {
298322
return
299323
}
300324

301-
let encoder = Encoder(codingPath: codingPath.appending(key: key), userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
302-
if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) {
325+
let encoder = Encoder(codingPath: codingPath.appending(key: key), userInfo: userInfo, strategy: strategy)
326+
if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) {
303327
setValue(value, forKey: key)
304328
} else {
305329
setValue(.null, forKey: key)
@@ -308,14 +332,14 @@ private extension KeyValueEncoder {
308332

309333
func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> {
310334
let path = codingPath.appending(key: key)
311-
let keyed = KeyedContainer<NestedKey>(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
335+
let keyed = KeyedContainer<NestedKey>(codingPath: path, userInfo: userInfo, strategy: strategy)
312336
storage[key.stringValue] = .provider(keyed.getEncodedValue)
313337
return KeyedEncodingContainer(keyed)
314338
}
315339

316340
func nestedUnkeyedContainer(forKey key: K) -> any UnkeyedEncodingContainer {
317341
let path = codingPath.appending(key: key)
318-
let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
342+
let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy)
319343
storage[key.stringValue] = .provider(unkeyed.getEncodedValue)
320344
return unkeyed
321345
}
@@ -326,7 +350,7 @@ private extension KeyValueEncoder {
326350

327351
func superEncoder(forKey key: Key) -> any Swift.Encoder {
328352
let path = codingPath.appending(key: key)
329-
let encoder = Encoder(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
353+
let encoder = Encoder(codingPath: path, userInfo: userInfo, strategy: strategy)
330354
storage[key.stringValue] = .provider(encoder.getEncodedValue)
331355
return encoder
332356
}
@@ -336,18 +360,18 @@ private extension KeyValueEncoder {
336360

337361
let codingPath: [any CodingKey]
338362
private let userInfo: [CodingUserInfoKey: Any]
339-
private let nilEncodingStrategy: NilEncodingStrategy
363+
private let strategy: EncodingStrategy
340364

341-
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) {
365+
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) {
342366
self.codingPath = codingPath
343367
self.userInfo = userInfo
344-
self.nilEncodingStrategy = nilEncodingStrategy
368+
self.strategy = strategy
345369
}
346370

347371
private var storage: [EncodedValue] = []
348372

349373
func getEncodedValue() throws -> EncodedValue {
350-
return try .value(storage.compactMap { try $0.getValue(strategy: nilEncodingStrategy) })
374+
return try .value(storage.compactMap { try $0.getValue(strategy: strategy.optionals) })
351375
}
352376

353377
public var count: Int {
@@ -431,9 +455,9 @@ private extension KeyValueEncoder {
431455
let encoder = Encoder(
432456
codingPath: codingPath.appending(index: count),
433457
userInfo: userInfo,
434-
nilEncodingStrategy: nilEncodingStrategy
458+
strategy: strategy
435459
)
436-
if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) {
460+
if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) {
437461
appendValue(value)
438462
} else {
439463
appendValue(.null)
@@ -442,21 +466,21 @@ private extension KeyValueEncoder {
442466

443467
func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer<NestedKey> {
444468
let path = codingPath.appending(index: count)
445-
let keyed = KeyedContainer<NestedKey>(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
469+
let keyed = KeyedContainer<NestedKey>(codingPath: path, userInfo: userInfo, strategy: strategy)
446470
storage.append(.provider(keyed.getEncodedValue))
447471
return KeyedEncodingContainer(keyed)
448472
}
449473

450474
func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer {
451475
let path = codingPath.appending(index: count)
452-
let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
476+
let unkeyed = UnkeyedContainer(codingPath: path, userInfo: userInfo, strategy: strategy)
453477
storage.append(.provider(unkeyed.getEncodedValue))
454478
return unkeyed
455479
}
456480

457481
func superEncoder() -> any Swift.Encoder {
458482
let path = codingPath.appending(index: count)
459-
let encoder = Encoder(codingPath: path, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
483+
let encoder = Encoder(codingPath: path, userInfo: userInfo, strategy: strategy)
460484
storage.append(.provider(encoder.getEncodedValue))
461485
return encoder
462486
}
@@ -466,12 +490,12 @@ private extension KeyValueEncoder {
466490

467491
let codingPath: [any CodingKey]
468492
private let userInfo: [CodingUserInfoKey: Any]
469-
private let nilEncodingStrategy: NilEncodingStrategy
493+
private let strategy: EncodingStrategy
470494

471-
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], nilEncodingStrategy: NilEncodingStrategy) {
495+
init(codingPath: [any CodingKey], userInfo: [CodingUserInfoKey: Any], strategy: EncodingStrategy) {
472496
self.codingPath = codingPath
473497
self.userInfo = userInfo
474-
self.nilEncodingStrategy = nilEncodingStrategy
498+
self.strategy = strategy
475499
}
476500

477501
private var value: EncodedValue?
@@ -547,8 +571,8 @@ private extension KeyValueEncoder {
547571
return
548572
}
549573

550-
let encoder = Encoder(codingPath: codingPath, userInfo: userInfo, nilEncodingStrategy: nilEncodingStrategy)
551-
if let value = try encoder.encodeToValue(value).getValue(strategy: nilEncodingStrategy) {
574+
let encoder = Encoder(codingPath: codingPath, userInfo: userInfo, strategy: strategy)
575+
if let value = try encoder.encodeToValue(value).getValue(strategy: strategy.optionals) {
552576
self.value = .value(value)
553577
} else {
554578
self.value = .null
@@ -557,6 +581,59 @@ private extension KeyValueEncoder {
557581
}
558582
}
559583

584+
extension KeyValueEncoder.KeyEncodingStrategy {
585+
586+
func makeStorageKey(for key: String) -> String {
587+
switch self {
588+
case .useDefaultKeys: return key
589+
case .convertToSnakeCase: return key.toSnakeCase()
590+
}
591+
}
592+
}
593+
594+
extension String {
595+
596+
func toSnakeCase() -> String {
597+
camelCaseWords
598+
.map { $0.lowercased() }
599+
.joined(separator: "_")
600+
}
601+
602+
var camelCaseWords: [Substring] {
603+
var words: [Range<String.Index>] = []
604+
var wordStart = startIndex
605+
var searchRange = index(after: wordStart)..<endIndex
606+
607+
while let upperCaseRange = self[searchRange].rangeOfCharacter(from: .uppercaseLetters, options: []) {
608+
let untilUpperCase = wordStart..<upperCaseRange.lowerBound
609+
words.append(untilUpperCase)
610+
611+
// Find next lowercase character
612+
searchRange = upperCaseRange.lowerBound..<searchRange.upperBound
613+
guard let lowerCaseRange = self[searchRange].rangeOfCharacter(from: .lowercaseLetters, options: []) else {
614+
// There are no more lower case letters. Just end here.
615+
wordStart = searchRange.lowerBound
616+
break
617+
}
618+
619+
// group runs of multiple capitals together instead of splitting into words
620+
let nextCharacterAfterCapital = self.index(after: upperCaseRange.lowerBound)
621+
if lowerCaseRange.lowerBound == nextCharacterAfterCapital {
622+
// Next is lowercase
623+
wordStart = upperCaseRange.lowerBound
624+
} else {
625+
// Multiple uppercase in sequence. Stop at the capital before the lower case character.
626+
let beforeLowerIndex = self.index(before: lowerCaseRange.lowerBound)
627+
words.append(upperCaseRange.lowerBound..<beforeLowerIndex)
628+
wordStart = beforeLowerIndex
629+
}
630+
searchRange = lowerCaseRange.upperBound..<searchRange.upperBound
631+
}
632+
words.append(wordStart..<searchRange.upperBound)
633+
return words.map { self[$0] }
634+
}
635+
}
636+
560637
extension Array where Element == any CodingKey {
561638

562639
func appending(key codingKey: any CodingKey) -> [any CodingKey] {

Tests/KeyValueEncoderTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,24 @@ struct KeyValueEncodedTests {
365365
)
366366
}
367367

368+
@Test
369+
func keyedContainer_Encodes_SnakeCase() throws {
370+
let shrimp = SnakeNode(firstName: "shrimp", lastName: "anemone")
371+
let node = SnakeNode(firstName: "fish", lastName: "chips", profileURL: "drop", relNODESLink: ["ocean": shrimp])
372+
373+
var encoder = KeyValueEncoder()
374+
encoder.keyEncodingStrategy = .convertToSnakeCase
375+
376+
#expect(
377+
try encoder.encode(node) as? NSDictionary == [
378+
"first_name": "fish",
379+
"surname": "chips",
380+
"profile_url": "drop",
381+
"rel_nodes_link": ["ocean": ["first_name": "shrimp", "surname": "anemone"]]
382+
]
383+
)
384+
}
385+
368386
@Test
369387
func unkeyedContainer_Encodes_Optionals() throws {
370388
#expect(
@@ -747,6 +765,18 @@ struct Node: Codable, Equatable {
747765
}
748766
}
749767

768+
struct SnakeNode: Codable, Equatable {
769+
var firstName: String
770+
var lastName: String
771+
var profileURL: String?
772+
var relNODESLink: [String: SnakeNode]?
773+
774+
enum CodingKeys: String, CodingKey {
775+
case firstName, profileURL, relNODESLink
776+
case lastName = "surname"
777+
}
778+
}
779+
750780
extension KeyValueEncoder.EncodedValue: Swift.Equatable {
751781

752782
public static func == (lhs: Self, rhs: Self) -> Bool {

0 commit comments

Comments
 (0)