Skip to content

Commit

Permalink
Add PersistableCache (#5)
Browse files Browse the repository at this point in the history
* Add PersistableCache

* Update README
  • Loading branch information
0xLeif committed Jun 10, 2023
1 parent 538f770 commit e8bb8f8
Show file tree
Hide file tree
Showing 6 changed files with 483 additions and 80 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Double>()

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<String, Double>()

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:
Expand Down
144 changes: 144 additions & 0 deletions Sources/Cache/Cache/PersistableCache.swift
Original file line number Diff line number Diff line change
@@ -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<String, Double>()
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<String, Double>()
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<Key, Value> 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<Key>(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<Key>(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)
}
}
46 changes: 39 additions & 7 deletions Sources/Cache/Dictionary/Dictionary+Cacheable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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

/**
Expand All @@ -184,16 +184,48 @@ extension Dictionary: Cacheable {
- Returns: A new dictionary containing the non-nil transformed keys and values.
*/
public func compactMapDictionary<NewKey: Hashable, NewValue>(
_ 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<NewKey: Hashable>(
_ 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<NewKey: Hashable>(
_ 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)
}
}
}
44 changes: 21 additions & 23 deletions Sources/Cache/JSON/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,26 @@ public struct JSON<Key: RawRepresentable & Hashable>: 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)
)
}

Expand All @@ -101,12 +101,12 @@ public struct JSON<Key: RawRepresentable & Hashable>: Cacheable where Key.RawVal
jsonDictionary = JSON<JSONKey>(data: data)
} else if let dictionary = value as? [String: Any] {
jsonDictionary = JSON<JSONKey>(
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] {
Expand Down Expand Up @@ -136,15 +136,13 @@ public struct JSON<Key: RawRepresentable & Hashable>: Cacheable where Key.RawVal
if let data = value as? Data {
jsonArray = JSON<JSONKey>.array(data: data)
} else if let array = value as? [[String: Any]] {
var values: [JSON<JSONKey>] = []

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<JSONKey>(data: jsonData))
return JSON<JSONKey>(data: jsonData)
}

jsonArray = values
} else if let array = value as? [[JSONKey: Any]] {
jsonArray = array.map { json in
JSON<JSONKey>(initialValues: json)
Expand Down

0 comments on commit e8bb8f8

Please sign in to comment.