Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 3 additions & 5 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,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)
}
}

Expand All @@ -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
}

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, 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() {
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