diff --git a/Package.swift b/Package.swift index 9c72b30..3497922 100644 --- a/Package.swift +++ b/Package.swift @@ -5,9 +5,9 @@ import CompilerPluginSupport let package = Package( name: "Defaults", platforms: [ - .macOS(.v11), - .iOS(.v14), - .tvOS(.v14), + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), .watchOS(.v9), .visionOS(.v1) ], diff --git a/Sources/Defaults/Defaults+Bridge.swift b/Sources/Defaults/Defaults+Bridge.swift index 473fb90..b8bd193 100644 --- a/Sources/Defaults/Defaults+Bridge.swift +++ b/Sources/Defaults/Defaults+Bridge.swift @@ -142,7 +142,7 @@ extension Defaults { } extension Defaults { - public struct DictionaryBridge: Bridge { + public struct DictionaryBridge: Bridge { public typealias Value = [Key: Element.Value] public typealias Serializable = [String: Element.Serializable] @@ -151,9 +151,8 @@ extension Defaults { return nil } - // `Key` which stored in `UserDefaults` have to be `String` return dictionary.reduce(into: Serializable()) { memo, tuple in - memo[String(tuple.key)] = Element.bridge.serialize(tuple.value) + memo[tuple.key.codingKey.stringValue] = Element.bridge.serialize(tuple.value) } } @@ -163,8 +162,7 @@ extension Defaults { } return dictionary.reduce(into: Value()) { memo, tuple in - // Use `LosslessStringConvertible` to create `Key` instance - guard let key = Key(tuple.key) else { + guard let key = Key(codingKey: tuple.key.codingKey) else { return } diff --git a/Sources/Defaults/Defaults+Extensions.swift b/Sources/Defaults/Defaults+Extensions.swift index 8d1c009..92511ae 100644 --- a/Sources/Defaults/Defaults+Extensions.swift +++ b/Sources/Defaults/Defaults+Extensions.swift @@ -132,7 +132,7 @@ extension Array: Defaults.Serializable where Element: Defaults.Serializable { public static var bridge: Defaults.ArrayBridge { Defaults.ArrayBridge() } } -extension Dictionary: Defaults.Serializable where Key: LosslessStringConvertible & Hashable, Value: Defaults.Serializable { +extension Dictionary: Defaults.Serializable where Key: CodingKeyRepresentable & Hashable, Value: Defaults.Serializable { public static var isNativelySupportedType: Bool { (Key.self is String.Type) && Value.isNativelySupportedType } public static var bridge: Defaults.DictionaryBridge { Defaults.DictionaryBridge() } } diff --git a/Tests/DefaultsTests/DefaultsBridgeTests.swift b/Tests/DefaultsTests/DefaultsBridgeTests.swift index e534c51..bdc7257 100644 --- a/Tests/DefaultsTests/DefaultsBridgeTests.swift +++ b/Tests/DefaultsTests/DefaultsBridgeTests.swift @@ -43,6 +43,40 @@ private enum TestEnumCodable: String, Codable, Defaults.Serializable { case beta = "beta_value" } +private enum Category: String, CodingKeyRepresentable { + case electronics + case books + case clothing +} + +private enum Priority: Int, CodingKeyRepresentable { + case low = 1 + case medium = 5 + case high = 10 +} + +// RawRepresentable types automatically get CodingKeyRepresentable conformance +// via stdlib's default implementation when RawValue is String or Int +private struct BundleIdentifier: RawRepresentable, Hashable, Codable, CodingKeyRepresentable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(_ value: String) { + self.init(rawValue: value) + } +} + +private struct UserID: RawRepresentable, Hashable, Codable, CodingKeyRepresentable { + let rawValue: Int + + init(rawValue: Int) { + self.rawValue = rawValue + } +} + @Suite(.serialized) final class DefaultsBridgeTests { init() { @@ -360,4 +394,65 @@ final class DefaultsBridgeTests { #expect(result.name == "fallback") #expect(result.value == -1) } + + @Test + func testEnumStringKeys() { + let key = Defaults.Key<[Category: String]>("categoryDict", default: [:], suite: suite_) + Defaults[key] = [.electronics: "Laptop", .books: "Guide"] + #expect(Defaults[key][.electronics] == "Laptop") + #expect(Defaults[key][.books] == "Guide") + } + + @Test + func testEnumIntKeys() { + enum Temperature: Int, Codable, Hashable, CodingKeyRepresentable { + case freezing = -10 + case zero = 0 + case boiling = 100 + } + + let key = Defaults.Key<[Temperature: String]>("tempDict", default: [:], suite: suite_) + Defaults[key] = [.freezing: "Cold", .zero: "Freezing", .boiling: "Hot"] + #expect(Defaults[key][.freezing] == "Cold") + #expect(Defaults[key][.zero] == "Freezing") + #expect(Defaults[key][.boiling] == "Hot") + } + + @Test + func testRawRepresentableKeys() { + let key = Defaults.Key<[BundleIdentifier: String]>("bundleDict", default: [:], suite: suite_) + Defaults[key] = [BundleIdentifier("com.app"): "App"] + #expect(Defaults[key][BundleIdentifier("com.app")] == "App") + } + + @Test + func testNestedDictionaries() { + let key = Defaults.Key<[Category: [Priority: String]]>("nestedDict", default: [:], suite: suite_) + Defaults[key] = [.electronics: [.high: "Urgent", .low: "Later"]] + #expect(Defaults[key][.electronics]?[.high] == "Urgent") + #expect(Defaults[key][.electronics]?[.low] == "Later") + } + + @Test + func testDictionaryPersistence() { + let key1 = Defaults.Key<[Category: String]>("persistDict", default: [:], suite: suite_) + Defaults[key1] = [.books: "Novel"] + + let key2 = Defaults.Key<[Category: String]>("persistDict", default: [:], suite: suite_) + #expect(Defaults[key2][.books] == "Novel") + } + + @Test + func testDictionaryRemoval() { + let key = Defaults.Key<[Priority: String]>("removeDict", default: [:], suite: suite_) + Defaults[key] = [.low: "A", .high: "B"] + + var updated = Defaults[key] + updated[.low] = nil + Defaults[key] = updated + + #expect(Defaults[key][.low] == nil) + #expect(Defaults[key][.high] == "B") + } + } diff --git a/readme.md b/readme.md index 36579c9..9259dd5 100644 --- a/readme.md +++ b/readme.md @@ -31,9 +31,9 @@ It's used in production by [all my apps](https://sindresorhus.com/apps) (4 milli ## Compatibility -- macOS 11+ -- iOS 14+ -- tvOS 14+ +- macOS 13+ +- iOS 16+ +- tvOS 16+ - watchOS 9+ - visionOS 1+ @@ -67,6 +67,8 @@ Add `https://github.com/sindresorhus/Defaults` in the [“Swift Package Manager 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]>]]`. +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`. + 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). You can easily add support for any custom type.