From e8bb8f89834f869c9236d38bc1744623912d70a5 Mon Sep 17 00:00:00 2001 From: Zach Date: Sat, 10 Jun 2023 15:19:24 -0600 Subject: [PATCH] Add PersistableCache (#5) * Add PersistableCache * Update README --- README.md | 30 +++ Sources/Cache/Cache/PersistableCache.swift | 144 ++++++++++++++ .../Dictionary/Dictionary+Cacheable.swift | 46 ++++- Sources/Cache/JSON/JSON.swift | 44 ++--- Tests/CacheTests/DictionaryTests.swift | 180 +++++++++++++----- Tests/CacheTests/PersistableCacheTests.swift | 119 ++++++++++++ 6 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 Sources/Cache/Cache/PersistableCache.swift create mode 100644 Tests/CacheTests/PersistableCacheTests.swift diff --git a/README.md b/README.md index 56d19f0..10e2e97 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,36 @@ if let answer = cache["Answer"] { The expiration duration of the cache can be set with the `ExpirationDuration` enumeration, which has three cases: `seconds`, `minutes`, and `hours`. Each case takes a single `UInt` argument to represent the duration of that time unit. +### PersistableCache + +The `PersistableCache` class is a cache that stores its contents persistently on disk using a JSON file. Use it to create a cache that persists its contents between application launches. The cache contents are automatically loaded from disk when initialized, and can be saved manually whenever required. + +To use `PersistableCache`, make sure that the specified key type conforms to both `RawRepresentable` and `Hashable` protocols. The `RawValue` of `Key` must be a `String` type. + + Here's an example of creating a cache, setting a value, and saving it to disk: + + ```swift + let cache = PersistableCache() + + cache["pi"] = Double.pi + + do { + try cache.save() + } catch { + print("Failed to save cache: \(error)") + } + ``` + + You can also load a previously saved cache from disk: + + ```swift + let cache = PersistableCache() + + let pi = cache["pi"] // pi == Double.pi + ``` + + Remember that the `save()` function may throw errors if the encoder fails to serialize the cache to JSON or the disk write operation fails. Make sure to handle the errors appropriately. + ### Advanced Usage You can use `Cache` as an observed object: diff --git a/Sources/Cache/Cache/PersistableCache.swift b/Sources/Cache/Cache/PersistableCache.swift new file mode 100644 index 0000000..ea25363 --- /dev/null +++ b/Sources/Cache/Cache/PersistableCache.swift @@ -0,0 +1,144 @@ +import Foundation + +/** + The `PersistableCache` class is a cache that stores its contents persistently on disk using a JSON file. + + Use `PersistableCache` to create a cache that persists its contents between application launches. The cache contents are automatically loaded from the disk when initialized. You can save the cache whenever using the `save()` function. + + Here's an example of creating a cache, setting a value, and saving it to disk: + + ```swift + let cache = PersistableCache() + + cache["pi"] = Double.pi + + do { + try cache.save() + } catch { + print("Failed to save cache: \(error)") + } + ``` + + You can also load a previously saved cache from disk: + + ```swift + let cache = PersistableCache() + + let pi = cache["pi"] // pi == Double.pi + ``` + + Note: You must make sure that the specified key type conforms to both `RawRepresentable` and `Hashable` protocols. The `RawValue` of `Key` must be a `String` type. + + Error Handling: The save() function may throw errors because either: + - A`JSONSerialization` error if the encoder fails to serialize the cache contents to JSON. + - An error if the `data.write(to:)` call fails to write the JSON data to disk. + + Make sure to handle the errors appropriately. + */ +open class PersistableCache< + Key: RawRepresentable & Hashable, Value +>: Cache where Key.RawValue == String { + private let lock: NSLock = NSLock() + + /// The name of the cache. This will be used as the filename when saving to disk. + public let name: String + + /// The URL of the persistable cache file's directory. + public let url: URL + + /** + Loads a persistable cache with a specified name and URL. + + - Parameters: + - name: A string specifying the name of the cache. + - url: A URL where the cache file directory will be or is stored. + */ + public init( + name: String, + url: URL + ) { + self.name = name + self.url = url + + var initialValues: [Key: Value] = [:] + + if let fileData = try? Data(contentsOf: url.fileURL(withName: name)) { + let loadedJSON = JSON(data: fileData) + initialValues = loadedJSON.values(ofType: Value.self) + } + + super.init(initialValues: initialValues) + } + + /** + Loads a persistable cache with a specified name and default URL. + + - Parameter name: A string specifying the name of the cache. + */ + public convenience init( + name: String + ) { + self.init( + name: name, + url: URL.defaultFileURL + ) + } + + /** + Loads the persistable cache with the given initial values. The `name` is set to `"\(Self.self)"`. + + - Parameter initialValues: A dictionary containing the initial cache contents. + */ + public required convenience init(initialValues: [Key: Value] = [:]) { + self.init(name: "\(Self.self)") + + initialValues.forEach { key, value in + set(value: value, forKey: key) + } + } + + /** + Saves the cache contents to disk. + + - Throws: + - A `JSONSerialization` error if the encoder fails to serialize the cache contents to JSON. + - An error if the `data.write(to:)` call fails to write the JSON data to disk. + */ + public func save() throws { + lock.lock() + let json = JSON(initialValues: allValues) + let data = try json.data() + try data.write(to: url.fileURL(withName: name)) + lock.unlock() + } + + /** + Deletes the cache file from disk. + + - Throws: An error if the file manager fails to remove the cache file. + */ + public func delete() throws { + lock.lock() + try FileManager.default.removeItem(at: url.fileURL(withName: name)) + lock.unlock() + } +} + +// MARK: - Private Helpers + +private extension URL { + static var defaultFileURL: URL { + FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + } + + func fileURL(withName name: String) -> URL { + guard + #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + else { return appendingPathComponent(name) } + + return appending(path: name) + } +} diff --git a/Sources/Cache/Dictionary/Dictionary+Cacheable.swift b/Sources/Cache/Dictionary/Dictionary+Cacheable.swift index 65caab1..a6c7416 100644 --- a/Sources/Cache/Dictionary/Dictionary+Cacheable.swift +++ b/Sources/Cache/Dictionary/Dictionary+Cacheable.swift @@ -2,7 +2,7 @@ extension Dictionary: Cacheable { /// Initializes the Dictionary instance with an optional dictionary of key-value pairs. /// /// - Parameter initialValues: the dictionary of key-value pairs (if any) to initialize the cache. - public init(initialValues: [Key : Value]) { + public init(initialValues: [Key: Value]) { self = initialValues } @@ -170,9 +170,9 @@ extension Dictionary: Cacheable { - Returns: A new dictionary containing the transformed keys and values. */ public func mapDictionary( - _ transform: (Key, Value) -> (NewKey, NewValue) - ) -> [NewKey: NewValue] { - compactMapDictionary(transform) + _ transform: @escaping (Key, Value) throws -> (NewKey, NewValue) + ) rethrows -> [NewKey: NewValue] { + try compactMapDictionary(transform) } /** @@ -184,16 +184,48 @@ extension Dictionary: Cacheable { - Returns: A new dictionary containing the non-nil transformed keys and values. */ public func compactMapDictionary( - _ transform: (Key, Value) -> (NewKey, NewValue)? - ) -> [NewKey: NewValue] { + _ transform: @escaping (Key, Value) throws -> (NewKey, NewValue)? + ) rethrows -> [NewKey: NewValue] { var dictionary: [NewKey: NewValue] = [:] for (key, value) in self { - if let (newKey, newValue) = transform(key, value) { + if let (newKey, newValue) = try transform(key, value) { dictionary[newKey] = newValue } } return dictionary } + + /** + Returns a new dictionary whose keys consist of the keys in the original dictionary transformed by the given closure. + + - Parameters: + - transform: A closure that takes a key from the dictionary as its argument and returns a new key. The returned key must be of the same type as the expected output for this method. + + - Returns: A new dictionary containing the transformed keys and the original values. + */ + public func mapKeys( + _ transform: @escaping (Key) throws -> NewKey + ) rethrows -> [NewKey: Value] { + try compactMapKeys(transform) + } + + /** + Returns a new dictionary whose keys consist of the non-nil results of transforming the keys in the original dictionary by the given closure. + + - Parameters: + - transform: A closure that takes a key from the dictionary as its argument and returns an optional new key. Each non-nil key will be included in the returned dictionary. The returned key must be of the same type as the expected output for this method. + + - Returns: A new dictionary containing the non-nil transformed keys and the original values. + */ + public func compactMapKeys( + _ transform: @escaping (Key) throws -> NewKey? + ) rethrows -> [NewKey: Value] { + try compactMapDictionary { key, value in + guard let newKey = try transform(key) else { return nil } + + return (newKey, value) + } + } } diff --git a/Sources/Cache/JSON/JSON.swift b/Sources/Cache/JSON/JSON.swift index c0ddc1c..c4b05cc 100644 --- a/Sources/Cache/JSON/JSON.swift +++ b/Sources/Cache/JSON/JSON.swift @@ -59,26 +59,26 @@ public struct JSON: Cacheable where Key.RawVal return jsonArray.compactMap { jsonObject in guard let jsonDictionary = jsonObject as? [String: Any] else { return nil } - var initialValues: [Key: Any] = [:] + return JSON( + initialValues: jsonDictionary.compactMapDictionary { jsonKey, jsonValue in + guard let key = Key(rawValue: jsonKey) else { return nil } - jsonDictionary.forEach { jsonKey, jsonValue in - guard let key = Key(rawValue: jsonKey) else { return } - - initialValues[key] = jsonValue - } - - return JSON(initialValues: initialValues) + return (key, jsonValue) + } + ) } } - /// Returns JSON data. - /// - /// - Throws: Errors are from `JSONSerialization.data(withJSONObject:)` + /** + Returns a `Data` object representing the JSON-encoded key-value pairs transformed into a dictionary where their keys are the raw values of their associated enum cases. + + - Throws: `JSONSerialization.data(withJSONObject:)` errors, if any. + + - Returns: A `Data` object that encodes the key-value pairs. + */ public func data() throws -> Data { try JSONSerialization.data( - withJSONObject: allValues.mapDictionary { key, value in - (key.rawValue, value) - } + withJSONObject: allValues.mapKeys(\.rawValue) ) } @@ -101,12 +101,12 @@ public struct JSON: Cacheable where Key.RawVal jsonDictionary = JSON(data: data) } else if let dictionary = value as? [String: Any] { jsonDictionary = JSON( - initialValues: dictionary.compactMapDictionary { key, value in + initialValues: dictionary.compactMapKeys { key in guard let key = JSONKey(rawValue: key) else { return nil } - return (key, value) + return key } ) } else if let dictionary = value as? [JSONKey: Any] { @@ -136,15 +136,13 @@ public struct JSON: Cacheable where Key.RawVal if let data = value as? Data { jsonArray = JSON.array(data: data) } else if let array = value as? [[String: Any]] { - var values: [JSON] = [] - - array.forEach { json in - guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else { return } + jsonArray = array.compactMap { json in + guard + let jsonData = try? JSONSerialization.data(withJSONObject: json) + else { return nil } - values.append(JSON(data: jsonData)) + return JSON(data: jsonData) } - - jsonArray = values } else if let array = value as? [[JSONKey: Any]] { jsonArray = array.map { json in JSON(initialValues: json) diff --git a/Tests/CacheTests/DictionaryTests.swift b/Tests/CacheTests/DictionaryTests.swift index e78dc9f..67ca616 100644 --- a/Tests/CacheTests/DictionaryTests.swift +++ b/Tests/CacheTests/DictionaryTests.swift @@ -5,104 +5,104 @@ final class DictionaryTests: XCTestCase { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertEqual(dictionary.allValues.count, 1) } - + func testGet_Success() { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertEqual(dictionary.get(.text), "Hello, World!") } - + func testGet_MissingKey() { enum Key { case text case missingKey } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertNil(dictionary.get(.missingKey)) } - + func testGet_InvalidType() { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertNil(dictionary.get(.text, as: Int.self)) } - + func testGet_InvalidWrappedValue() { enum Key { case text } - + let value: Any?? = "Hello, World!" - + let dictionary: [Key: Any] = [Key: Any]( initialValues: [ .text: value as Any ] ) - + XCTAssertNotNil(dictionary.get(.text, as: String.self)) } - + func testResolve_Success() throws { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertEqual(try dictionary.resolve(.text), "Hello, World!") } - + func testResolve_MissingKey() throws { enum Key { case text case missingKey } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + do { _ = try dictionary.resolve(.missingKey) - + XCTFail("resolve should throw") } catch { XCTAssertEqual( @@ -111,21 +111,21 @@ final class DictionaryTests: XCTestCase { ) } } - + func testResolve_InvalidType() throws { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + do { _ = try dictionary.resolve(.text, as: Double.self) - + XCTFail("resolve should throw") } catch { XCTAssertEqual( @@ -134,107 +134,187 @@ final class DictionaryTests: XCTestCase { ) } } - + func testSet() { enum Key { case text } - + var dictionary: [Key: String] = [Key: String]() - + dictionary.set(value: "Hello, World!", forKey: .text) - + XCTAssertEqual(dictionary.get(.text), "Hello, World!") } - + func testRemove() { enum Key { case text } - + var dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertEqual(dictionary.get(.text), "Hello, World!") - + dictionary.remove(.text) - + XCTAssertNil(dictionary.get(.text)) } - + func testContains() { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssert(dictionary.contains(.text)) } - + func testRequire_Success() throws { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertNoThrow(try dictionary.require(.text)) } - + func testRequire_Missing() throws { enum Key { case text case missingKey } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertThrowsError(try dictionary.require(.missingKey)) } - + func testRequireSet_Success() throws { enum Key { case text } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertNoThrow(try dictionary.require(keys: [.text])) } - + func testRequireSet_Missing() throws { enum Key { case text case missingKey } - + let dictionary: [Key: String] = [Key: String]( initialValues: [ .text: "Hello, World!" ] ) - + XCTAssertThrowsError(try dictionary.require(keys: [.text, .missingKey])) } + + func testMapDictionary() { + let expectedDictionary: [String: String] = [ + "1": "1", + "2": "2", + "3": "3" + ] + + let initialDictionary: [Int: Int] = [ + 1: 1, + 2: 2, + 3: 3 + ] + + let mappedDictionary: [String: String] = initialDictionary.mapDictionary { key, value in + ("\(key)", "\(value)") + } + + XCTAssertEqual(mappedDictionary, expectedDictionary) + } + + func testCompactMapDictionary() { + let expectedDictionary: [String: String] = [ + "2": "2" + ] + + let initialDictionary: [Int: Int] = [ + 1: 1, + 2: 2, + 3: 3 + ] + + let mappedDictionary: [String: String] = initialDictionary.compactMapDictionary { key, value in + guard key.isMultiple(of: 2) else { return nil } + + return ("\(key)", "\(value)") + } + + XCTAssertEqual(mappedDictionary, expectedDictionary) + } + + func testMapKeys() { + let expectedDictionary: [String: Int] = [ + "1": 1, + "2": 2, + "3": 3 + ] + + let initialDictionary: [Int: Int] = [ + 1: 1, + 2: 2, + 3: 3 + ] + + let mappedDictionary: [String: Int] = initialDictionary.mapKeys { key in + "\(key)" + } + + XCTAssertEqual(mappedDictionary, expectedDictionary) + } + + func testCompactMapKeys() { + let expectedDictionary: [String: Int] = [ + "2": 2 + ] + + let initialDictionary: [Int: Int] = [ + 1: 1, + 2: 2, + 3: 3 + ] + + let mappedDictionary: [String: Int] = initialDictionary.compactMapKeys { key in + guard key.isMultiple(of: 2) else { return nil } + + return "\(key)" + } + + XCTAssertEqual(mappedDictionary, expectedDictionary) + } } diff --git a/Tests/CacheTests/PersistableCacheTests.swift b/Tests/CacheTests/PersistableCacheTests.swift new file mode 100644 index 0000000..a165ed2 --- /dev/null +++ b/Tests/CacheTests/PersistableCacheTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import Cache + +final class PersistableCacheTests: XCTestCase { + func testPersistableCacheInitialValues() throws { + enum Key: String { + case text + case author + } + + let cache: PersistableCache = PersistableCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache.allValues.count, 1) + + try cache.save() + + + enum SomeOtherKey: String { + case text + } + + let failedLoadedCache: PersistableCache = PersistableCache() + + XCTAssertEqual(failedLoadedCache.allValues.count, 0) + XCTAssertEqual(failedLoadedCache.url, cache.url) + XCTAssertNotEqual(failedLoadedCache.name, cache.name) + + let loadedCache: PersistableCache = PersistableCache( + initialValues: [ + .author: "Leif" + ] + ) + + XCTAssertEqual(loadedCache.allValues.count, 2) + + try loadedCache.delete() + + let loadedDeletedCache: PersistableCache = PersistableCache() + + XCTAssertEqual(loadedDeletedCache.allValues.count, 0) + + let expectedName = "PersistableCache" + let expectedURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + + XCTAssertEqual( + [cache.name, loadedCache.name, loadedDeletedCache.name], + [String](repeating: expectedName, count: 3) + ) + + XCTAssertEqual( + [cache.url, loadedCache.url, loadedDeletedCache.url], + [URL](repeating: expectedURL, count: 3) + ) + } + + func testPersistableCacheName() throws { + enum Key: String { + case text + case author + } + + let cache: PersistableCache = PersistableCache(name: "test") + + cache[.text] = "Hello, World!" + + XCTAssertEqual(cache.allValues.count, 1) + + try cache.save() + + let loadedCache: PersistableCache = PersistableCache(name: "test") + + loadedCache[.author] = "Leif" + + XCTAssertEqual(loadedCache.allValues.count, 2) + + try loadedCache.save() + + enum SomeOtherKey: String { + case text + } + + let otherKeyedLoadedCache: PersistableCache = PersistableCache(name: "test") + + XCTAssertEqual(otherKeyedLoadedCache.allValues.count, 1) + XCTAssertEqual(otherKeyedLoadedCache.url, cache.url) + XCTAssertEqual(otherKeyedLoadedCache.name, cache.name) + + XCTAssertEqual(otherKeyedLoadedCache[.text], loadedCache[.text]) + + try loadedCache.delete() + + let loadedDeletedCache: PersistableCache = PersistableCache(name: "test") + + XCTAssertEqual(loadedDeletedCache.allValues.count, 0) + + let expectedName = "test" + let expectedURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + + XCTAssertEqual( + [cache.name, loadedCache.name, otherKeyedLoadedCache.name, loadedDeletedCache.name], + [String](repeating: expectedName, count: 4) + ) + + XCTAssertEqual( + [cache.url, loadedCache.url, otherKeyedLoadedCache.url, loadedDeletedCache.url], + [URL](repeating: expectedURL, count: 4) + ) + } +}