From 58a69793ef220cca4606d7c8cce842b0ee1feb14 Mon Sep 17 00:00:00 2001 From: Jesse Squires Date: Thu, 11 Jan 2024 11:23:22 -0800 Subject: [PATCH] Implement support for `Codable` types (#92) Closes #72 --- CHANGELOG.md | 7 ++- Foil.xcodeproj/project.pbxproj | 2 +- README.md | 33 ++++++++++- Sources/UserDefaultsSerializable.swift | 77 +++++++++++++++++++------- Tests/IntegrationTests.swift | 9 +++ Tests/TestSettings.swift | 10 ++++ Tests/WrappedDefaultTests.swift | 20 +++++++ 7 files changed, 132 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219c333..92196ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,14 @@ NEXT This release closes the [5.0.0 milestone](https://github.com/jessesquires/Foil/milestone/7?closed=1). +### Breaking + +- The `UserDefaultsSerializable` protocol has changed. Previously, it declared the initializer `init(storedValue:)`. It is now failable: `init?(storedValue:)`. This change was necessary to accommodate `Codable` types (see below). ([#92](https://github.com/jessesquires/Foil/issues/92), [@jessesquires](https://github.com/jessesquires)) + ### New -- Added [privacy manifest](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files) for iOS 17 +- Support for `Codable` types. (Please don't abuse this. See the docs.) ([#72](https://github.com/jessesquires/Foil/issues/72), [#92](https://github.com/jessesquires/Foil/issues/92), [@jessesquires](https://github.com/jessesquires)) +- Added [privacy manifest](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files) for iOS 17. ([@jessesquires](https://github.com/jessesquires)) ### Changed diff --git a/Foil.xcodeproj/project.pbxproj b/Foil.xcodeproj/project.pbxproj index 695e42b..b7ec467 100644 --- a/Foil.xcodeproj/project.pbxproj +++ b/Foil.xcodeproj/project.pbxproj @@ -86,8 +86,8 @@ 0BF54A8225BF589E008484F8 /* Sources */ = { isa = PBXGroup; children = ( - 0B315D1E2B4E051C000F5034 /* PrivacyInfo.xcprivacy */, 1CC00F2328DAB1FC00EC2C63 /* ObserverTrampoline.swift */, + 0B315D1E2B4E051C000F5034 /* PrivacyInfo.xcprivacy */, 0BF54A9C25BF58B1008484F8 /* UserDefaults+Extensions.swift */, 0BF5FD9525C14A960003B078 /* UserDefaultsSerializable.swift */, 0BF5FD8F25C14A7D0003B078 /* WrappedDefault.swift */, diff --git a/README.md b/README.md index e109bb0..552922d 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ let observer = AppSettings.shared.observe(\.userId, options: [.new]) { settings, #### Using Combine -**Note:** that `average` does not need the `@objc dynamic` annotation, `.receiveValue` will fire immediately with the current value of `average` and on every change after. +> [!NOTE] +> The `average` does not need the `@objc dynamic` annotation, `.receiveValue` will fire immediately with the current value of `average` and on every change after. ```swift AppSettings.shared.$average @@ -107,7 +108,8 @@ AppSettings.shared.$average #### Combine Alternative with KVO -**Note:** in this case, `userId` needs the `@objc dynamic` annotation and `AppSettings` needs to inherit from `NSObject`. Then `receiveValue` will fire only on changes to wrapped object's value. It will not publish the initial value as in the example above. +> [!NOTE] +> In this case, `userId` needs the `@objc dynamic` annotation and `AppSettings` needs to inherit from `NSObject`. Then `receiveValue` will fire only on changes to wrapped object's value. It will not publish the initial value as in the example above. ```swift AppSettings.shared @@ -122,7 +124,10 @@ AppSettings.shared The following types are supported by default for use with `@WrappedDefault`. -Adding support for custom types is possible by conforming to `UserDefaultsSerializable`. However, **this is highly discouraged**. `UserDefaults` is not intended for storing complex data structures and object graphs. You should probably be using a proper database (or serializing to disk via `Codable`) instead. +> [!IMPORTANT] +> Adding support for custom types is possible by conforming to `UserDefaultsSerializable`. However, **this is highly discouraged** as all `plist` types are supported by default. `UserDefaults` is not intended for storing complex data structures and object graphs. You should probably be using a proper database (or serializing to disk via `Codable`) instead. +> +> While `Foil` supports storing `Codable` types by default, you should **use this sparingly** and _only_ for small objects with few properties. - `Bool` - `Int` @@ -137,6 +142,28 @@ Adding support for custom types is possible by conforming to `UserDefaultsSerial - `Set` - `Dictionary` - `RawRepresentable` types +- `Codable` types + +> [!WARNING] +> If you are storing custom `Codable` types and using the default implementation of `UserDefaultsSerializable` provided by `Foil`, then **you must use the optional variant of the property wrapper**, `@WrappedDefaultOptional`. This will allow you to make breaking changes to your `Codable` type (e.g., adding or removing a property). Alternatively, you can provide a custom implementation of `Codable` that supports migration, or provide a custom implementation of `UserDefaultsSerializable` that handles encoding/decoding failures. See the example below. + +**Codable Example:** +```swift +// Note: uses the default implementation of UserDefaultsSerializable +struct User: Codable, UserDefaultsSerializable { + let id: UUID + let name: String +} + +// Yes, do this +@WrappedDefaultOptional(key: "user") +var user: User? + +// NO, do NOT this +// This will crash if you change User by adding/removing properties +@WrappedDefault(key: "user") +var user = User() +``` ## Additional Resources diff --git a/Sources/UserDefaultsSerializable.swift b/Sources/UserDefaultsSerializable.swift index 4872092..9129ce6 100644 --- a/Sources/UserDefaultsSerializable.swift +++ b/Sources/UserDefaultsSerializable.swift @@ -29,6 +29,7 @@ import Foundation /// - `Set` /// - `Dictionary` /// - `RawRepresentable` types +/// - `Codable` types public protocol UserDefaultsSerializable { /// The type of the value that is stored in `UserDefaults`. @@ -37,17 +38,17 @@ public protocol UserDefaultsSerializable { /// The value to store in `UserDefaults`. var storedValue: StoredValue { get } - /// Initializes the object using the provided value. + /// Initializes the object using the provided value, or returns `nil` if initialization fails. /// /// - Parameter storedValue: The previously store value fetched from `UserDefaults`. - init(storedValue: StoredValue) + init?(storedValue: StoredValue) } /// :nodoc: extension Bool: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -56,7 +57,7 @@ extension Bool: UserDefaultsSerializable { extension Int: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -65,7 +66,7 @@ extension Int: UserDefaultsSerializable { extension UInt: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -74,7 +75,7 @@ extension UInt: UserDefaultsSerializable { extension Float: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -83,7 +84,7 @@ extension Float: UserDefaultsSerializable { extension Double: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -92,7 +93,7 @@ extension Double: UserDefaultsSerializable { extension String: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -101,7 +102,7 @@ extension String: UserDefaultsSerializable { extension URL: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -110,7 +111,7 @@ extension URL: UserDefaultsSerializable { extension Date: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } @@ -119,41 +120,50 @@ extension Date: UserDefaultsSerializable { extension Data: UserDefaultsSerializable { public var storedValue: Self { self } - public init(storedValue: Self) { + public init?(storedValue: Self) { self = storedValue } } /// :nodoc: +// Note: yes, compactMap will remove nil values, but collections of optionals are not valid plist types. +// If a value is nil, it simply gets removed from UserDefaults. +// Thus, this will never happen. For example, you cannot store [Int?], only [Int]. extension Array: UserDefaultsSerializable where Element: UserDefaultsSerializable { public var storedValue: [Element.StoredValue] { - self.map { $0.storedValue } + self.compactMap { $0.storedValue } } - public init(storedValue: [Element.StoredValue]) { - self = storedValue.map { Element(storedValue: $0) } + public init?(storedValue: [Element.StoredValue]) { + self = storedValue.compactMap { Element(storedValue: $0) } } } /// :nodoc: +// Note: yes, compactMap will remove nil values, but collections of optionals are not valid plist types. +// If a value is nil, it simply gets removed from UserDefaults. +// Thus, this will never happen. For example, you cannot store [Int?], only [Int]. extension Set: UserDefaultsSerializable where Element: UserDefaultsSerializable { public var storedValue: [Element.StoredValue] { self.map { $0.storedValue } } - public init(storedValue: [Element.StoredValue]) { - self = Set(storedValue.map { Element(storedValue: $0) }) + public init?(storedValue: [Element.StoredValue]) { + self = Set(storedValue.compactMap { Element(storedValue: $0) }) } } /// :nodoc: +// Note: yes, compactMap will remove nil values, but collections of optionals are not valid plist types. +// If a value is nil, it simply gets removed from UserDefaults. +// Thus, this will never happen. For example, you cannot store [Int?], only [Int]. extension Dictionary: UserDefaultsSerializable where Key == String, Value: UserDefaultsSerializable { public var storedValue: [String: Value.StoredValue] { - self.mapValues { $0.storedValue } + self.compactMapValues { $0.storedValue } } - public init(storedValue: [String: Value.StoredValue]) { - self = storedValue.mapValues { Value(storedValue: $0) } + public init?(storedValue: [String: Value.StoredValue]) { + self = storedValue.compactMapValues { Value(storedValue: $0) } } } @@ -161,7 +171,32 @@ extension Dictionary: UserDefaultsSerializable where Key == String, Value: UserD extension UserDefaultsSerializable where Self: RawRepresentable, Self.RawValue: UserDefaultsSerializable { public var storedValue: RawValue.StoredValue { self.rawValue.storedValue } - public init(storedValue: RawValue.StoredValue) { - self = Self(rawValue: Self.RawValue(storedValue: storedValue))! + public init?(storedValue: RawValue.StoredValue) { + guard let rawValue = Self.RawValue(storedValue: storedValue), + let value = Self(rawValue: rawValue) else { + return nil + } + self = value + } +} + +/// :nodoc: +extension UserDefaultsSerializable where Self: Codable { + public var storedValue: Data? { + do { + return try JSONEncoder().encode(self) + } catch { + assertionFailure("[Foil] Encoding error: \(error)") + return nil + } + } + + public init?(storedValue: Data?) { + do { + self = try JSONDecoder().decode(Self.self, from: storedValue ?? Data()) + } catch { + assertionFailure("[Foil] Decoding error: \(error)") + return nil + } } } diff --git a/Tests/IntegrationTests.swift b/Tests/IntegrationTests.swift index e149380..6e398dd 100644 --- a/Tests/IntegrationTests.swift +++ b/Tests/IntegrationTests.swift @@ -163,4 +163,13 @@ final class IntegrationTests: XCTestCase { let expectedValue = ["key1": TestFruit.apple.rawValue, "key2": TestFruit.orange.rawValue] XCTAssertEqual(TestSettings.store.fetch("customRawRepresented"), expectedValue) } + + func test_Integration_Codable() { + let defaultValue = self.settings.user + XCTAssertNil(defaultValue) + + let newValue = User(id: UUID(), name: "John Doe", highScore: 9_999, lastLogin: Date()) + self.settings.user = newValue + XCTAssertEqual(self.settings.user, newValue) + } } diff --git a/Tests/TestSettings.swift b/Tests/TestSettings.swift index ced3804..a433eb1 100644 --- a/Tests/TestSettings.swift +++ b/Tests/TestSettings.swift @@ -33,6 +33,13 @@ struct TestCustomRepresented: RawRepresentable, UserDefaultsSerializable { } } +struct User: Hashable, Codable, UserDefaultsSerializable { + let id: UUID + let name: String + let highScore: Double + let lastLogin: Date +} + final class TestSettings: NSObject { static let suiteName = UUID().uuidString @@ -87,4 +94,7 @@ final class TestSettings: NSObject { @WrappedDefaultOptional(key: "userId", userDefaults: store) @objc dynamic var userId: String? + + @WrappedDefaultOptional(key: "user", userDefaults: store) + var user: User? } diff --git a/Tests/WrappedDefaultTests.swift b/Tests/WrappedDefaultTests.swift index 82da433..ee4cc14 100644 --- a/Tests/WrappedDefaultTests.swift +++ b/Tests/WrappedDefaultTests.swift @@ -318,4 +318,24 @@ final class WrappedDefaultTests: XCTestCase { XCTAssertNil(defaultValue) XCTAssertNil(model.wrappedValue) } + + func test_WrappedValue_Codable_Optional() { + let key = "key_\(#function)" + var model = WrappedDefaultOptional(key: key, userDefaults: self.testDefaults) + + let defaultValue: User? = self.testDefaults.fetchOptional(key) + XCTAssertNil(defaultValue) + XCTAssertNil(model.wrappedValue) + + let newValue = User(id: UUID(), name: "John Doe", highScore: 9_000, lastLogin: Date()) + model.wrappedValue = newValue + XCTAssertEqual(self.testDefaults.fetch(key), newValue) + XCTAssertEqual(model.wrappedValue, newValue) + + model.wrappedValue = nil + XCTAssertNil(model.wrappedValue) + + let fetchedValue: User? = self.testDefaults.fetchOptional(key) + XCTAssertNil(fetchedValue) + } }