Skip to content

Commit

Permalink
Merge pull request #62 from mergesort/caching
Browse files Browse the repository at this point in the history
Implementing an additional caching layer for StoredValue
  • Loading branch information
mergesort authored Apr 12, 2024
2 parents f48a087 + 221d9f8 commit 31e22ea
Show file tree
Hide file tree
Showing 11 changed files with 329 additions and 290 deletions.
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

0 comments on commit 31e22ea

Please sign in to comment.