Skip to content

Commit e67583b

Browse files
committed
Fix JSONEncoder key conversion for CodingKeyRepresentable dictionary keys
When encoding dictionaries with CodingKeyRepresentable keys (e.g., custom enum or struct keys), JSONEncoder was incorrectly applying key encoding strategies like convertToSnakeCase to the dictionary keys themselves, rather than treating them as semantic keys that should remain unchanged. This change: - Expands _JSONStringDictionaryEncodableMarker protocol coverage from String-keyed dictionaries to any CodingKeyRepresentable-keyed dictionaries - Renames the protocol to _JSONCodingKeyRepresentableDictionaryEncodableMarker to better reflect its expanded scope - Updates the encoding logic to handle CodingKeyRepresentable keys by converting them directly to their string representation Fixes encoding behavior where Dictionary<CustomKey, Value> keys were being transformed (e.g., "leaveMeAlone" -> "leave_me_alone") when they should preserve their original form.
1 parent 57b6c0c commit e67583b

File tree

2 files changed

+26
-8
lines changed

2 files changed

+26
-8
lines changed

Sources/FoundationEssentials/JSON/JSONEncoder.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,14 +1184,16 @@ private extension __JSONEncoder {
11841184
}
11851185
}
11861186

1187-
func wrap(_ dict: [String : Encodable], for additionalKey: (some CodingKey)? = _CodingKey?.none) throws -> JSONEncoderValue? {
1187+
func wrap(_ dict: _JSONCodingKeyRepresentableDictionaryEncodableMarker, for additionalKey: (some CodingKey)? = _CodingKey?.none) throws -> JSONEncoderValue? {
1188+
let dict = dict as! [AnyHashable: Encodable]
11881189
var result = [String: JSONEncoderValue]()
11891190
result.reserveCapacity(dict.count)
11901191

11911192
let encoder = __JSONEncoder(options: self.options, ownerEncoder: self)
11921193
for (key, value) in dict {
1193-
encoder.codingKey = _CodingKey(stringValue: key)
1194-
result[key] = try encoder.wrap(value)
1194+
let stringKey = (key.base as! CodingKeyRepresentable).codingKey.stringValue
1195+
encoder.codingKey = _CodingKey(stringValue: stringKey)
1196+
result[stringKey] = try encoder.wrap(value)
11951197
}
11961198

11971199
return .object(result)
@@ -1214,8 +1216,8 @@ private extension __JSONEncoder {
12141216
return self.wrap(url.absoluteString)
12151217
} else if let decimal = value as? Decimal {
12161218
return .number(decimal.description)
1217-
} else if let encodable = value as? _JSONStringDictionaryEncodableMarker {
1218-
return try self.wrap(encodable as! [String:Encodable], for: additionalKey)
1219+
} else if let encodable = value as? _JSONCodingKeyRepresentableDictionaryEncodableMarker {
1220+
return try self.wrap(encodable, for: additionalKey)
12191221
} else if let array = value as? _JSONDirectArrayEncodable {
12201222
if options.outputFormatting.contains(.prettyPrinted) {
12211223
let (bytes, lengths) = try array.individualElementRepresentation(encoder: self, additionalKey)
@@ -1362,11 +1364,11 @@ extension JSONEncoder : @unchecked Sendable {}
13621364
// Special-casing Support
13631365
//===----------------------------------------------------------------------===//
13641366

1365-
/// A marker protocol used to determine whether a value is a `String`-keyed `Dictionary`
1367+
/// A marker protocol used to determine whether a value is a `CodingKeyRepresentable`-keyed `Dictionary`
13661368
/// containing `Encodable` values (in which case it should be exempt from key conversion strategies).
1367-
private protocol _JSONStringDictionaryEncodableMarker { }
1369+
private protocol _JSONCodingKeyRepresentableDictionaryEncodableMarker { }
13681370

1369-
extension Dictionary : _JSONStringDictionaryEncodableMarker where Key == String, Value: Encodable { }
1371+
extension Dictionary : _JSONCodingKeyRepresentableDictionaryEncodableMarker where Key: CodingKeyRepresentable, Value: Encodable { }
13701372

13711373
/// A protocol used to determine whether a value is an `Array` containing values that allow
13721374
/// us to bypass UnkeyedEncodingContainer overhead by directly encoding the contents as

Tests/FoundationEssentialsTests/JSONEncoderTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2333,6 +2333,22 @@ extension JSONEncoderTests {
23332333

23342334
#expect(expected == resultString)
23352335
}
2336+
2337+
@Test func encodingDictionaryCodingKeyRepresentableKeyConversionUntouched() throws {
2338+
struct Key: RawRepresentable, CodingKeyRepresentable, Hashable, Codable {
2339+
let rawValue: String
2340+
}
2341+
2342+
let expected = "{\"leaveMeAlone\":\"test\"}"
2343+
let toEncode: [Key: String] = [Key(rawValue: "leaveMeAlone"): "test"]
2344+
2345+
let encoder = JSONEncoder()
2346+
encoder.keyEncodingStrategy = .convertToSnakeCase
2347+
let resultData = try encoder.encode(toEncode)
2348+
let resultString = String(bytes: resultData, encoding: .utf8)
2349+
2350+
#expect(expected == resultString)
2351+
}
23362352

23372353
@Test func keyStrategySnakeGeneratedAndCustom() throws {
23382354
// Test that this works with a struct that has automatically generated keys

0 commit comments

Comments
 (0)