Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing an additional caching layer for StoredValue #62

Merged
merged 3 commits into from
Apr 12, 2024
Merged
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
25 changes: 25 additions & 0 deletions Sources/Boutique/CachedValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

/// `CachedValue` exists internally for the purpose of creating a reference value, preventing the need
/// to create a `JSONDecoder` and invoke a decode step every time we need to access a `StoredValue` externally.
internal final class CachedValue<Item: Codable> {
private var cachedValue: Item?
public var retrieveValue: () -> Item

init(retrieveValue: @escaping () -> Item) {
self.retrieveValue = retrieveValue
self.cachedValue = self.retrieveValue()
}

func set(_ value: Item) {
self.cachedValue = value
}

var wrappedValue: Item? {
if let cachedValue {
cachedValue
} else {
self.retrieveValue()
}
}
}
7 changes: 6 additions & 1 deletion Sources/Boutique/SecurelyStoredValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ public struct SecurelyStoredValue<Item: Codable> {
if self.wrappedValue == nil {
try self.insert(value)
} else {
try self.update(value)
// This call to `remove` is a temporary workaround for broken functionality when trying to update a value.
// Since updating a value does not seem to work, I've rewritten `set` to first set a `nil` value
// then the desired value, which will effectively call `set` with a new value, which does work.
// This will be fixed in the future, and we will restore the call-site to say `self.update(value)`.
try self.remove()
try self.insert(value)
}
} else {
try self.remove()
Expand Down
1 change: 0 additions & 1 deletion Sources/Boutique/StoredValue+Binding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public extension StoredValue {
}
}


public extension SecurelyStoredValue {
/// A convenient way to create a `Binding` from a `SecurelyStoredValue`.
///
Expand Down
10 changes: 9 additions & 1 deletion Sources/Boutique/StoredValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,24 @@ public struct StoredValue<Item: Codable> {
private let userDefaults: UserDefaults
private let itemSubject: CurrentValueSubject<Item, Never>

private var cachedValue: CachedValue<Item>

public init(wrappedValue: Item, key: String, storage userDefaults: UserDefaults = UserDefaults.standard) {
self.key = key
self.defaultValue = wrappedValue
self.userDefaults = userDefaults

let initialValue = Self.storedValue(forKey: key, userDefaults: userDefaults, defaultValue: defaultValue)
self.itemSubject = CurrentValueSubject(initialValue)

self.cachedValue = CachedValue(retrieveValue: {
Self.storedValue(forKey: key, userDefaults: userDefaults, defaultValue: initialValue)
})
}

/// The currently stored value
public var wrappedValue: Item {
Self.storedValue(forKey: self.key, userDefaults: self.userDefaults, defaultValue: self.defaultValue)
self.cachedValue.retrieveValue()
}

/// A ``StoredValue`` which exposes ``set(_:)`` and ``reset()`` functions alongside a ``publisher``.
Expand Down Expand Up @@ -94,6 +100,7 @@ public struct StoredValue<Item: Codable> {
let boxedValue = BoxedValue(value: value)
if let data = try? JSONCoders.encoder.encode(boxedValue) {
self.userDefaults.set(data, forKey: self.key)
self.cachedValue.set(value)
self.itemSubject.send(value)
}
}
Expand Down Expand Up @@ -123,6 +130,7 @@ public struct StoredValue<Item: Codable> {
let boxedValue = BoxedValue(value: self.defaultValue)
if let data = try? JSONCoders.encoder.encode(boxedValue) {
self.userDefaults.set(data, forKey: self.key)
self.cachedValue.set(self.defaultValue)
self.itemSubject.send(self.defaultValue)
}
}
Expand Down
156 changes: 78 additions & 78 deletions Tests/BoutiqueTests/AsyncStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,193 +20,193 @@ final class AsyncStoreTests: XCTestCase {

@MainActor
func testInsertingItem() async throws {
try await asyncStore.insert(BoutiqueItem.coat)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.coat))
try await asyncStore.insert(.coat)
XCTAssertTrue(asyncStore.items.contains(.coat))

try await asyncStore.insert(BoutiqueItem.belt)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.belt))
try await asyncStore.insert(.belt)
XCTAssertTrue(asyncStore.items.contains(.belt))
XCTAssertEqual(asyncStore.items.count, 2)
}

@MainActor
func testInsertingItems() async throws {
try await asyncStore.insert([BoutiqueItem.coat, BoutiqueItem.sweater, BoutiqueItem.sweater, BoutiqueItem.purse])
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
try await asyncStore.insert([.coat, .sweater, .sweater, .purse])
XCTAssertTrue(asyncStore.items.contains(.coat))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertTrue(asyncStore.items.contains(.purse))
}

@MainActor
func testInsertingDuplicateItems() async throws {
XCTAssertTrue(asyncStore.items.isEmpty)
try await asyncStore.insert(BoutiqueItem.allItems)
try await asyncStore.insert(.allItems)
XCTAssertEqual(asyncStore.items.count, 4)
}

@MainActor
func testReadingItems() async throws {
try await asyncStore.insert(BoutiqueItem.allItems)
try await asyncStore.insert(.allItems)

XCTAssertEqual(asyncStore.items[0], BoutiqueItem.coat)
XCTAssertEqual(asyncStore.items[1], BoutiqueItem.sweater)
XCTAssertEqual(asyncStore.items[2], BoutiqueItem.purse)
XCTAssertEqual(asyncStore.items[3], BoutiqueItem.belt)
XCTAssertEqual(asyncStore.items[0], .coat)
XCTAssertEqual(asyncStore.items[1], .sweater)
XCTAssertEqual(asyncStore.items[2], .purse)
XCTAssertEqual(asyncStore.items[3], .belt)

XCTAssertEqual(asyncStore.items.count, 4)
}

@MainActor
func testReadingPersistedItems() async throws {
try await asyncStore.insert(BoutiqueItem.allItems)
try await asyncStore.insert(.allItems)

// The new store has to fetch items from disk.
let newStore = try await Store<BoutiqueItem>(
storage: SQLiteStorageEngine.default(appendingPath: "Tests"),
cacheIdentifier: \.merchantID)

XCTAssertEqual(newStore.items[0], BoutiqueItem.coat)
XCTAssertEqual(newStore.items[1], BoutiqueItem.sweater)
XCTAssertEqual(newStore.items[2], BoutiqueItem.purse)
XCTAssertEqual(newStore.items[3], BoutiqueItem.belt)
XCTAssertEqual(newStore.items[0], .coat)
XCTAssertEqual(newStore.items[1], .sweater)
XCTAssertEqual(newStore.items[2], .purse)
XCTAssertEqual(newStore.items[3], .belt)

XCTAssertEqual(newStore.items.count, 4)
}

@MainActor
func testRemovingItems() async throws {
try await asyncStore.insert(BoutiqueItem.allItems)
try await asyncStore.remove(BoutiqueItem.coat)
try await asyncStore.insert(.allItems)
try await asyncStore.remove(.coat)

XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertFalse(asyncStore.items.contains(.coat))

XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertTrue(asyncStore.items.contains(.purse))

try await asyncStore.remove([BoutiqueItem.sweater, BoutiqueItem.purse])
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.purse))
try await asyncStore.remove([.sweater, .purse])
XCTAssertFalse(asyncStore.items.contains(.sweater))
XCTAssertFalse(asyncStore.items.contains(.purse))
}

@MainActor
func testRemoveAll() async throws {
try await asyncStore.insert(BoutiqueItem.coat)
try await asyncStore.insert(.coat)
XCTAssertEqual(asyncStore.items.count, 1)
try await asyncStore.removeAll()

try await asyncStore.insert(BoutiqueItem.uniqueItems)
try await asyncStore.insert(.uniqueItems)
XCTAssertEqual(asyncStore.items.count, 4)
try await asyncStore.removeAll()
XCTAssertTrue(asyncStore.items.isEmpty)
}

@MainActor
func testChainingInsertOperations() async throws {
try await asyncStore.insert(BoutiqueItem.uniqueItems)
try await asyncStore.insert(.uniqueItems)

try await asyncStore
.remove(BoutiqueItem.coat)
.insert(BoutiqueItem.belt)
.insert(BoutiqueItem.belt)
.remove(.coat)
.insert(.belt)
.insert(.belt)
.run()

XCTAssertEqual(asyncStore.items.count, 3)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertTrue(asyncStore.items.contains(.purse))
XCTAssertTrue(asyncStore.items.contains(.belt))
XCTAssertFalse(asyncStore.items.contains(.coat))

try await asyncStore.removeAll()

try await asyncStore
.insert(BoutiqueItem.belt)
.insert(BoutiqueItem.coat)
.remove([BoutiqueItem.belt])
.insert(BoutiqueItem.sweater)
.insert(.belt)
.insert(.coat)
.remove([.belt])
.insert(.sweater)
.run()

XCTAssertEqual(asyncStore.items.count, 2)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertTrue(asyncStore.items.contains(.coat))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertFalse(asyncStore.items.contains(.belt))

try await asyncStore
.insert(BoutiqueItem.belt)
.insert(BoutiqueItem.coat)
.insert(BoutiqueItem.purse)
.remove([BoutiqueItem.belt, .coat])
.insert(BoutiqueItem.sweater)
.insert(.belt)
.insert(.coat)
.insert(.purse)
.remove([.belt, .coat])
.insert(.sweater)
.run()

XCTAssertEqual(asyncStore.items.count, 2)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertTrue(asyncStore.items.contains(.purse))
XCTAssertFalse(asyncStore.items.contains(.coat))
XCTAssertFalse(asyncStore.items.contains(.belt))

try await asyncStore.removeAll()

try await asyncStore
.insert(BoutiqueItem.coat)
.insert([BoutiqueItem.purse, BoutiqueItem.belt])
.insert(.coat)
.insert([.purse, .belt])
.run()

XCTAssertEqual(asyncStore.items.count, 3)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertTrue(asyncStore.items.contains(.purse))
XCTAssertTrue(asyncStore.items.contains(.belt))
XCTAssertTrue(asyncStore.items.contains(.coat))
}

@MainActor
func testChainingRemoveOperations() async throws {
try await asyncStore
.insert(BoutiqueItem.uniqueItems)
.remove(BoutiqueItem.belt)
.remove(BoutiqueItem.purse)
.insert(.uniqueItems)
.remove(.belt)
.remove(.purse)
.run()

XCTAssertEqual(asyncStore.items.count, 2)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.sweater))
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertTrue(asyncStore.items.contains(.sweater))
XCTAssertTrue(asyncStore.items.contains(.coat))

try await asyncStore.insert(BoutiqueItem.uniqueItems)
try await asyncStore.insert(.uniqueItems)
XCTAssertEqual(asyncStore.items.count, 4)

try await asyncStore
.remove([BoutiqueItem.sweater, BoutiqueItem.coat])
.remove(BoutiqueItem.belt)
.remove([.sweater, .coat])
.remove(.belt)
.run()

XCTAssertEqual(asyncStore.items.count, 1)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertTrue(asyncStore.items.contains(.purse))

try await asyncStore
.removeAll()
.insert(BoutiqueItem.belt)
.insert(.belt)
.run()

XCTAssertEqual(asyncStore.items.count, 1)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertTrue(asyncStore.items.contains(.belt))

try await asyncStore
.removeAll()
.remove(BoutiqueItem.belt)
.insert(BoutiqueItem.belt)
.remove(.belt)
.insert(.belt)
.run()

XCTAssertEqual(asyncStore.items.count, 1)
XCTAssertTrue(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertTrue(asyncStore.items.contains(.belt))
}

@MainActor
func testChainingOperationsDontExecuteUnlessRun() async throws {
let operation = try await asyncStore
.insert(BoutiqueItem.coat)
.insert([BoutiqueItem.purse, BoutiqueItem.belt])
.insert(.coat)
.insert([.purse, .belt])

XCTAssertEqual(asyncStore.items.count, 0)
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.purse))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.belt))
XCTAssertFalse(asyncStore.items.contains(BoutiqueItem.coat))
XCTAssertFalse(asyncStore.items.contains(.purse))
XCTAssertFalse(asyncStore.items.contains(.belt))
XCTAssertFalse(asyncStore.items.contains(.coat))

// Adding this line to get rid of the error about
// `operation` being unused, given that's the point of the test.
Expand All @@ -215,7 +215,7 @@ final class AsyncStoreTests: XCTestCase {

@MainActor
func testPublishedItemsSubscription() async throws {
let uniqueItems = BoutiqueItem.uniqueItems
let uniqueItems = [BoutiqueItem].uniqueItems
let expectation = XCTestExpectation(description: "uniqueItems is published and read")

asyncStore.$items
Expand Down
Loading
Loading