Skip to content

Commit 768aeb6

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 32fd38c commit 768aeb6

File tree

2 files changed

+27
-9
lines changed

2 files changed

+27
-9
lines changed

Sources/FoundationEssentials/JSON/JSONEncoder.swift

Lines changed: 11 additions & 9 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,9 +1216,9 @@ 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 !options.keyEncodingStrategy.isDefault, let encodable = value as? _JSONStringDictionaryEncodableMarker {
1218-
return try self.wrap(encodable as! [String:Encodable], for: additionalKey)
1219-
} else if let array = _asDirectArrayEncodable(value, for: additionalKey) {
1219+
} else if !options.keyEncodingStrategy.isDefault, let encodable = value as? _JSONCodingKeyRepresentableDictionaryEncodableMarker {
1220+
return try self.wrap(encodable, for: additionalKey)
1221+
} else if let array = value as? _JSONDirectArrayEncodable {
12201222
if options.outputFormatting.contains(.prettyPrinted) {
12211223
let (bytes, lengths) = try array.individualElementRepresentation(encoder: self, additionalKey)
12221224
return .directArray(bytes, lengths: lengths)
@@ -1398,11 +1400,11 @@ extension JSONEncoder : @unchecked Sendable {}
13981400
// Special-casing Support
13991401
//===----------------------------------------------------------------------===//
14001402

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

1405-
extension Dictionary : _JSONStringDictionaryEncodableMarker where Key == String, Value: Encodable { }
1407+
extension Dictionary : _JSONCodingKeyRepresentableDictionaryEncodableMarker where Key: CodingKeyRepresentable, Value: Encodable { }
14061408

14071409
/// A protocol used to determine whether a value is an `Array` containing values that allow
14081410
/// 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)