Skip to content

Commit e8bb8f8

Browse files
authored
Add PersistableCache (#5)
* Add PersistableCache * Update README
1 parent 538f770 commit e8bb8f8

File tree

6 files changed

+483
-80
lines changed

6 files changed

+483
-80
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,36 @@ if let answer = cache["Answer"] {
107107

108108
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.
109109

110+
### PersistableCache
111+
112+
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.
113+
114+
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.
115+
116+
Here's an example of creating a cache, setting a value, and saving it to disk:
117+
118+
```swift
119+
let cache = PersistableCache<String, Double>()
120+
121+
cache["pi"] = Double.pi
122+
123+
do {
124+
try cache.save()
125+
} catch {
126+
print("Failed to save cache: \(error)")
127+
}
128+
```
129+
130+
You can also load a previously saved cache from disk:
131+
132+
```swift
133+
let cache = PersistableCache<String, Double>()
134+
135+
let pi = cache["pi"] // pi == Double.pi
136+
```
137+
138+
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.
139+
110140
### Advanced Usage
111141

112142
You can use `Cache` as an observed object:
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import Foundation
2+
3+
/**
4+
The `PersistableCache` class is a cache that stores its contents persistently on disk using a JSON file.
5+
6+
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.
7+
8+
Here's an example of creating a cache, setting a value, and saving it to disk:
9+
10+
```swift
11+
let cache = PersistableCache<String, Double>()
12+
13+
cache["pi"] = Double.pi
14+
15+
do {
16+
try cache.save()
17+
} catch {
18+
print("Failed to save cache: \(error)")
19+
}
20+
```
21+
22+
You can also load a previously saved cache from disk:
23+
24+
```swift
25+
let cache = PersistableCache<String, Double>()
26+
27+
let pi = cache["pi"] // pi == Double.pi
28+
```
29+
30+
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.
31+
32+
Error Handling: The save() function may throw errors because either:
33+
- A`JSONSerialization` error if the encoder fails to serialize the cache contents to JSON.
34+
- An error if the `data.write(to:)` call fails to write the JSON data to disk.
35+
36+
Make sure to handle the errors appropriately.
37+
*/
38+
open class PersistableCache<
39+
Key: RawRepresentable & Hashable, Value
40+
>: Cache<Key, Value> where Key.RawValue == String {
41+
private let lock: NSLock = NSLock()
42+
43+
/// The name of the cache. This will be used as the filename when saving to disk.
44+
public let name: String
45+
46+
/// The URL of the persistable cache file's directory.
47+
public let url: URL
48+
49+
/**
50+
Loads a persistable cache with a specified name and URL.
51+
52+
- Parameters:
53+
- name: A string specifying the name of the cache.
54+
- url: A URL where the cache file directory will be or is stored.
55+
*/
56+
public init(
57+
name: String,
58+
url: URL
59+
) {
60+
self.name = name
61+
self.url = url
62+
63+
var initialValues: [Key: Value] = [:]
64+
65+
if let fileData = try? Data(contentsOf: url.fileURL(withName: name)) {
66+
let loadedJSON = JSON<Key>(data: fileData)
67+
initialValues = loadedJSON.values(ofType: Value.self)
68+
}
69+
70+
super.init(initialValues: initialValues)
71+
}
72+
73+
/**
74+
Loads a persistable cache with a specified name and default URL.
75+
76+
- Parameter name: A string specifying the name of the cache.
77+
*/
78+
public convenience init(
79+
name: String
80+
) {
81+
self.init(
82+
name: name,
83+
url: URL.defaultFileURL
84+
)
85+
}
86+
87+
/**
88+
Loads the persistable cache with the given initial values. The `name` is set to `"\(Self.self)"`.
89+
90+
- Parameter initialValues: A dictionary containing the initial cache contents.
91+
*/
92+
public required convenience init(initialValues: [Key: Value] = [:]) {
93+
self.init(name: "\(Self.self)")
94+
95+
initialValues.forEach { key, value in
96+
set(value: value, forKey: key)
97+
}
98+
}
99+
100+
/**
101+
Saves the cache contents to disk.
102+
103+
- Throws:
104+
- A `JSONSerialization` error if the encoder fails to serialize the cache contents to JSON.
105+
- An error if the `data.write(to:)` call fails to write the JSON data to disk.
106+
*/
107+
public func save() throws {
108+
lock.lock()
109+
let json = JSON<Key>(initialValues: allValues)
110+
let data = try json.data()
111+
try data.write(to: url.fileURL(withName: name))
112+
lock.unlock()
113+
}
114+
115+
/**
116+
Deletes the cache file from disk.
117+
118+
- Throws: An error if the file manager fails to remove the cache file.
119+
*/
120+
public func delete() throws {
121+
lock.lock()
122+
try FileManager.default.removeItem(at: url.fileURL(withName: name))
123+
lock.unlock()
124+
}
125+
}
126+
127+
// MARK: - Private Helpers
128+
129+
private extension URL {
130+
static var defaultFileURL: URL {
131+
FileManager.default.urls(
132+
for: .documentDirectory,
133+
in: .userDomainMask
134+
)[0]
135+
}
136+
137+
func fileURL(withName name: String) -> URL {
138+
guard
139+
#available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
140+
else { return appendingPathComponent(name) }
141+
142+
return appending(path: name)
143+
}
144+
}

Sources/Cache/Dictionary/Dictionary+Cacheable.swift

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ extension Dictionary: Cacheable {
22
/// Initializes the Dictionary instance with an optional dictionary of key-value pairs.
33
///
44
/// - Parameter initialValues: the dictionary of key-value pairs (if any) to initialize the cache.
5-
public init(initialValues: [Key : Value]) {
5+
public init(initialValues: [Key: Value]) {
66
self = initialValues
77
}
88

@@ -170,9 +170,9 @@ extension Dictionary: Cacheable {
170170
- Returns: A new dictionary containing the transformed keys and values.
171171
*/
172172
public func mapDictionary<NewKey: Hashable, NewValue>(
173-
_ transform: (Key, Value) -> (NewKey, NewValue)
174-
) -> [NewKey: NewValue] {
175-
compactMapDictionary(transform)
173+
_ transform: @escaping (Key, Value) throws -> (NewKey, NewValue)
174+
) rethrows -> [NewKey: NewValue] {
175+
try compactMapDictionary(transform)
176176
}
177177

178178
/**
@@ -184,16 +184,48 @@ extension Dictionary: Cacheable {
184184
- Returns: A new dictionary containing the non-nil transformed keys and values.
185185
*/
186186
public func compactMapDictionary<NewKey: Hashable, NewValue>(
187-
_ transform: (Key, Value) -> (NewKey, NewValue)?
188-
) -> [NewKey: NewValue] {
187+
_ transform: @escaping (Key, Value) throws -> (NewKey, NewValue)?
188+
) rethrows -> [NewKey: NewValue] {
189189
var dictionary: [NewKey: NewValue] = [:]
190190

191191
for (key, value) in self {
192-
if let (newKey, newValue) = transform(key, value) {
192+
if let (newKey, newValue) = try transform(key, value) {
193193
dictionary[newKey] = newValue
194194
}
195195
}
196196

197197
return dictionary
198198
}
199+
200+
/**
201+
Returns a new dictionary whose keys consist of the keys in the original dictionary transformed by the given closure.
202+
203+
- Parameters:
204+
- 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.
205+
206+
- Returns: A new dictionary containing the transformed keys and the original values.
207+
*/
208+
public func mapKeys<NewKey: Hashable>(
209+
_ transform: @escaping (Key) throws -> NewKey
210+
) rethrows -> [NewKey: Value] {
211+
try compactMapKeys(transform)
212+
}
213+
214+
/**
215+
Returns a new dictionary whose keys consist of the non-nil results of transforming the keys in the original dictionary by the given closure.
216+
217+
- Parameters:
218+
- 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.
219+
220+
- Returns: A new dictionary containing the non-nil transformed keys and the original values.
221+
*/
222+
public func compactMapKeys<NewKey: Hashable>(
223+
_ transform: @escaping (Key) throws -> NewKey?
224+
) rethrows -> [NewKey: Value] {
225+
try compactMapDictionary { key, value in
226+
guard let newKey = try transform(key) else { return nil }
227+
228+
return (newKey, value)
229+
}
230+
}
199231
}

Sources/Cache/JSON/JSON.swift

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,26 @@ public struct JSON<Key: RawRepresentable & Hashable>: Cacheable where Key.RawVal
5959
return jsonArray.compactMap { jsonObject in
6060
guard let jsonDictionary = jsonObject as? [String: Any] else { return nil }
6161

62-
var initialValues: [Key: Any] = [:]
62+
return JSON(
63+
initialValues: jsonDictionary.compactMapDictionary { jsonKey, jsonValue in
64+
guard let key = Key(rawValue: jsonKey) else { return nil }
6365

64-
jsonDictionary.forEach { jsonKey, jsonValue in
65-
guard let key = Key(rawValue: jsonKey) else { return }
66-
67-
initialValues[key] = jsonValue
68-
}
69-
70-
return JSON(initialValues: initialValues)
66+
return (key, jsonValue)
67+
}
68+
)
7169
}
7270
}
7371

74-
/// Returns JSON data.
75-
///
76-
/// - Throws: Errors are from `JSONSerialization.data(withJSONObject:)`
72+
/**
73+
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.
74+
75+
- Throws: `JSONSerialization.data(withJSONObject:)` errors, if any.
76+
77+
- Returns: A `Data` object that encodes the key-value pairs.
78+
*/
7779
public func data() throws -> Data {
7880
try JSONSerialization.data(
79-
withJSONObject: allValues.mapDictionary { key, value in
80-
(key.rawValue, value)
81-
}
81+
withJSONObject: allValues.mapKeys(\.rawValue)
8282
)
8383
}
8484

@@ -101,12 +101,12 @@ public struct JSON<Key: RawRepresentable & Hashable>: Cacheable where Key.RawVal
101101
jsonDictionary = JSON<JSONKey>(data: data)
102102
} else if let dictionary = value as? [String: Any] {
103103
jsonDictionary = JSON<JSONKey>(
104-
initialValues: dictionary.compactMapDictionary { key, value in
104+
initialValues: dictionary.compactMapKeys { key in
105105
guard let key = JSONKey(rawValue: key) else {
106106
return nil
107107
}
108108

109-
return (key, value)
109+
return key
110110
}
111111
)
112112
} else if let dictionary = value as? [JSONKey: Any] {
@@ -136,15 +136,13 @@ public struct JSON<Key: RawRepresentable & Hashable>: Cacheable where Key.RawVal
136136
if let data = value as? Data {
137137
jsonArray = JSON<JSONKey>.array(data: data)
138138
} else if let array = value as? [[String: Any]] {
139-
var values: [JSON<JSONKey>] = []
140-
141-
array.forEach { json in
142-
guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else { return }
139+
jsonArray = array.compactMap { json in
140+
guard
141+
let jsonData = try? JSONSerialization.data(withJSONObject: json)
142+
else { return nil }
143143

144-
values.append(JSON<JSONKey>(data: jsonData))
144+
return JSON<JSONKey>(data: jsonData)
145145
}
146-
147-
jsonArray = values
148146
} else if let array = value as? [[JSONKey: Any]] {
149147
jsonArray = array.map { json in
150148
JSON<JSONKey>(initialValues: json)

0 commit comments

Comments
 (0)