Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
],
Expand Down
15 changes: 9 additions & 6 deletions Sources/Defaults/Defaults+Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ extension Defaults {
}

extension Defaults {
public struct DictionaryBridge<Key: LosslessStringConvertible & Hashable, Element: Serializable>: Bridge {
public struct DictionaryBridge<Key: CodingKeyRepresentable & Hashable, Element: Serializable>: Bridge {
public typealias Value = [Key: Element.Value]
public typealias Serializable = [String: Element.Serializable]

Expand All @@ -151,9 +151,10 @@ 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)
if let serialized = Element.bridge.serialize(tuple.value) {
memo[tuple.key.codingKey.stringValue] = serialized
}
}
}

Expand All @@ -163,12 +164,14 @@ 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),
let value = Element.bridge.deserialize(tuple.value)
else {
return
}

memo[key] = Element.bridge.deserialize(tuple.value)
memo[key] = value
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Defaults/Defaults+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ extension Array: Defaults.Serializable where Element: Defaults.Serializable {
public static var bridge: Defaults.ArrayBridge<Element> { 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<Key, Value> { Defaults.DictionaryBridge() }
}
Expand Down
95 changes: 95 additions & 0 deletions Tests/DefaultsTests/DefaultsBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,40 @@
case beta = "beta_value"
}

private enum Category: String, Codable, Hashable, CodingKeyRepresentable {
case electronics
case books
case clothing
}

private enum Priority: Int, Codable, Hashable, 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() {
Expand Down Expand Up @@ -360,4 +394,65 @@
#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")
}

Check warning on line 457 in Tests/DefaultsTests/DefaultsBridgeTests.swift

View workflow job for this annotation

GitHub Actions / lint

Vertical Whitespace before Closing Braces Violation: Don't include vertical whitespace (empty line) before closing braces (vertical_whitespace_closing_braces)

Check warning on line 457 in Tests/DefaultsTests/DefaultsBridgeTests.swift

View workflow job for this annotation

GitHub Actions / lint

Vertical Whitespace before Closing Braces Violation: Don't include vertical whitespace (empty line) before closing braces (vertical_whitespace_closing_braces)
}
8 changes: 5 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

Expand Down Expand Up @@ -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.
Expand Down