Skip to content

Commit

Permalink
Implement support for Codable types (#92)
Browse files Browse the repository at this point in the history
Closes #72
  • Loading branch information
jessesquires authored Jan 11, 2024
1 parent 45ea080 commit 58a6979
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 26 deletions.
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Foil.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */,
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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

Expand Down
77 changes: 56 additions & 21 deletions Sources/UserDefaultsSerializable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -119,49 +120,83 @@ 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) }
}
}

/// :nodoc:
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
}
}
}
9 changes: 9 additions & 0 deletions Tests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 10 additions & 0 deletions Tests/TestSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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?
}
20 changes: 20 additions & 0 deletions Tests/WrappedDefaultTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>(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)
}
}

0 comments on commit 58a6979

Please sign in to comment.