Skip to content

Commit d37c2ea

Browse files
committed
Add CodingKeyRepresentable support for dictionary keys instead of LosslessStringConvertible
Fixes #99
1 parent 9a16755 commit d37c2ea

File tree

5 files changed

+113
-13
lines changed

5 files changed

+113
-13
lines changed

Package.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import CompilerPluginSupport
55
let package = Package(
66
name: "Defaults",
77
platforms: [
8-
.macOS(.v11),
9-
.iOS(.v14),
10-
.tvOS(.v14),
8+
.macOS(.v13),
9+
.iOS(.v16),
10+
.tvOS(.v16),
1111
.watchOS(.v9),
1212
.visionOS(.v1)
1313
],

Sources/Defaults/Defaults+Bridge.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ extension Defaults {
142142
}
143143

144144
extension Defaults {
145-
public struct DictionaryBridge<Key: LosslessStringConvertible & Hashable, Element: Serializable>: Bridge {
145+
public struct DictionaryBridge<Key: CodingKeyRepresentable & Hashable, Element: Serializable>: Bridge {
146146
public typealias Value = [Key: Element.Value]
147147
public typealias Serializable = [String: Element.Serializable]
148148

@@ -151,9 +151,10 @@ extension Defaults {
151151
return nil
152152
}
153153

154-
// `Key` which stored in `UserDefaults` have to be `String`
155154
return dictionary.reduce(into: Serializable()) { memo, tuple in
156-
memo[String(tuple.key)] = Element.bridge.serialize(tuple.value)
155+
if let serialized = Element.bridge.serialize(tuple.value) {
156+
memo[tuple.key.codingKey.stringValue] = serialized
157+
}
157158
}
158159
}
159160

@@ -163,12 +164,14 @@ extension Defaults {
163164
}
164165

165166
return dictionary.reduce(into: Value()) { memo, tuple in
166-
// Use `LosslessStringConvertible` to create `Key` instance
167-
guard let key = Key(tuple.key) else {
167+
guard
168+
let key = Key(codingKey: tuple.key.codingKey),
169+
let value = Element.bridge.deserialize(tuple.value)
170+
else {
168171
return
169172
}
170173

171-
memo[key] = Element.bridge.deserialize(tuple.value)
174+
memo[key] = value
172175
}
173176
}
174177
}

Sources/Defaults/Defaults+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ extension Array: Defaults.Serializable where Element: Defaults.Serializable {
132132
public static var bridge: Defaults.ArrayBridge<Element> { Defaults.ArrayBridge() }
133133
}
134134

135-
extension Dictionary: Defaults.Serializable where Key: LosslessStringConvertible & Hashable, Value: Defaults.Serializable {
135+
extension Dictionary: Defaults.Serializable where Key: CodingKeyRepresentable & Hashable, Value: Defaults.Serializable {
136136
public static var isNativelySupportedType: Bool { (Key.self is String.Type) && Value.isNativelySupportedType }
137137
public static var bridge: Defaults.DictionaryBridge<Key, Value> { Defaults.DictionaryBridge() }
138138
}

Tests/DefaultsTests/DefaultsBridgeTests.swift

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,40 @@ private enum TestEnumCodable: String, Codable, Defaults.Serializable {
4343
case beta = "beta_value"
4444
}
4545

46+
private enum Category: String, Codable, Hashable, CodingKeyRepresentable {
47+
case electronics
48+
case books
49+
case clothing
50+
}
51+
52+
private enum Priority: Int, Codable, Hashable, CodingKeyRepresentable {
53+
case low = 1
54+
case medium = 5
55+
case high = 10
56+
}
57+
58+
// RawRepresentable types automatically get CodingKeyRepresentable conformance
59+
// via stdlib's default implementation when RawValue is String or Int
60+
private struct BundleIdentifier: RawRepresentable, Hashable, Codable, CodingKeyRepresentable {
61+
let rawValue: String
62+
63+
init(rawValue: String) {
64+
self.rawValue = rawValue
65+
}
66+
67+
init(_ value: String) {
68+
self.init(rawValue: value)
69+
}
70+
}
71+
72+
private struct UserID: RawRepresentable, Hashable, Codable, CodingKeyRepresentable {
73+
let rawValue: Int
74+
75+
init(rawValue: Int) {
76+
self.rawValue = rawValue
77+
}
78+
}
79+
4680
@Suite(.serialized)
4781
final class DefaultsBridgeTests {
4882
init() {
@@ -360,4 +394,65 @@ final class DefaultsBridgeTests {
360394
#expect(result.name == "fallback")
361395
#expect(result.value == -1)
362396
}
397+
398+
@Test
399+
func testEnumStringKeys() {
400+
let key = Defaults.Key<[Category: String]>("categoryDict", default: [:], suite: suite_)
401+
Defaults[key] = [.electronics: "Laptop", .books: "Guide"]
402+
#expect(Defaults[key][.electronics] == "Laptop")
403+
#expect(Defaults[key][.books] == "Guide")
404+
}
405+
406+
@Test
407+
func testEnumIntKeys() {
408+
enum Temperature: Int, Codable, Hashable, CodingKeyRepresentable {
409+
case freezing = -10
410+
case zero = 0
411+
case boiling = 100
412+
}
413+
414+
let key = Defaults.Key<[Temperature: String]>("tempDict", default: [:], suite: suite_)
415+
Defaults[key] = [.freezing: "Cold", .zero: "Freezing", .boiling: "Hot"]
416+
#expect(Defaults[key][.freezing] == "Cold")
417+
#expect(Defaults[key][.zero] == "Freezing")
418+
#expect(Defaults[key][.boiling] == "Hot")
419+
}
420+
421+
@Test
422+
func testRawRepresentableKeys() {
423+
let key = Defaults.Key<[BundleIdentifier: String]>("bundleDict", default: [:], suite: suite_)
424+
Defaults[key] = [BundleIdentifier("com.app"): "App"]
425+
#expect(Defaults[key][BundleIdentifier("com.app")] == "App")
426+
}
427+
428+
@Test
429+
func testNestedDictionaries() {
430+
let key = Defaults.Key<[Category: [Priority: String]]>("nestedDict", default: [:], suite: suite_)
431+
Defaults[key] = [.electronics: [.high: "Urgent", .low: "Later"]]
432+
#expect(Defaults[key][.electronics]?[.high] == "Urgent")
433+
#expect(Defaults[key][.electronics]?[.low] == "Later")
434+
}
435+
436+
@Test
437+
func testDictionaryPersistence() {
438+
let key1 = Defaults.Key<[Category: String]>("persistDict", default: [:], suite: suite_)
439+
Defaults[key1] = [.books: "Novel"]
440+
441+
let key2 = Defaults.Key<[Category: String]>("persistDict", default: [:], suite: suite_)
442+
#expect(Defaults[key2][.books] == "Novel")
443+
}
444+
445+
@Test
446+
func testDictionaryRemoval() {
447+
let key = Defaults.Key<[Priority: String]>("removeDict", default: [:], suite: suite_)
448+
Defaults[key] = [.low: "A", .high: "B"]
449+
450+
var updated = Defaults[key]
451+
updated[.low] = nil
452+
Defaults[key] = updated
453+
454+
#expect(Defaults[key][.low] == nil)
455+
#expect(Defaults[key][.high] == "B")
456+
}
457+
363458
}

readme.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ It's used in production by [all my apps](https://sindresorhus.com/apps) (4 milli
3131

3232
## Compatibility
3333

34-
- macOS 11+
35-
- iOS 14+
36-
- tvOS 14+
34+
- macOS 13+
35+
- iOS 16+
36+
- tvOS 16+
3737
- watchOS 9+
3838
- visionOS 1+
3939

@@ -67,6 +67,8 @@ Add `https://github.com/sindresorhus/Defaults` in the [“Swift Package Manager
6767

6868
Defaults also support the above types wrapped in `Array`, `Set`, `Dictionary`, `Range`, `ClosedRange`, and even wrapped in nested types. For example, `[[String: Set<[String: Int]>]]`.
6969

70+
Dictionary keys: Any type conforming to `CodingKeyRepresentable` can be used as dictionary keys. This includes `String`, `Int`, enums with `String` or `Int` raw values, and custom types that conform to `CodingKeyRepresentable`.
71+
7072
For more types, see the [enum example](#enum-example), [`Codable` example](#codable-example), or [advanced Usage](#advanced-usage). For more examples, see [Tests/DefaultsTests](./Tests/DefaultsTests).
7173

7274
You can easily add support for any custom type.

0 commit comments

Comments
 (0)