diff --git a/README.md b/README.md index e630864..56d19f0 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,29 @@ You can also just set the value to `nil` using the subscripts cache[.text] = nil ``` +### ExpiringCache + +The `ExpiringCache` class is a cache that retains and returns objects for a specific duration set by the `ExpirationDuration` enumeration. Objects stored in the cache are automatically removed when their expiration duration has passed. + +#### Usage + +```swift +// Create an instance of the cache with a duration of 5 minutes +let cache = ExpiringCache(duration: .minutes(5)) + +// Store a value in the cache with a key +cache["Answer"] = 42 + +// Retrieve a value from the cache using its key +if let answer = cache["Answer"] { + print("The answer is \(answer)") +} +``` + +#### Expiration Duration + +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. + ### Advanced Usage You can use `Cache` as an observed object: diff --git a/Sources/Cache/Cache/ExpiringCache+subscript.swift b/Sources/Cache/Cache/ExpiringCache+subscript.swift new file mode 100644 index 0000000..7331090 --- /dev/null +++ b/Sources/Cache/Cache/ExpiringCache+subscript.swift @@ -0,0 +1,39 @@ +extension ExpiringCache { + /** + Accesses the value associated with the given key for reading and writing. + + - Parameters: + - key: The key to retrieve the value for. + - Returns: The value stored in the cache for the given key, or `nil` if it doesn't exist. + - Notes: If `nil` is assigned to the subscript, then the key-value pair is removed from the cache. + */ + public subscript(_ key: Key) -> Value? { + get { + get(key, as: Value.self) + } + set(newValue) { + guard let newValue else { + return remove(key) + } + + set(value: newValue, forKey: key) + } + } + + /** + Accesses the value associated with the given key for reading and writing, optionally using a default value if the key is missing. + + - Parameters: + - key: The key to retrieve the value for. + - default: The default value to be returned if the key is missing. + - Returns: The value stored in the cache for the given key, or the default value if it doesn't exist. + */ + public subscript(_ key: Key, default value: Value) -> Value { + get { + get(key, as: Value.self) ?? value + } + set(newValue) { + set(value: newValue, forKey: key) + } + } +} diff --git a/Sources/Cache/Cache/ExpiringCache.swift b/Sources/Cache/Cache/ExpiringCache.swift new file mode 100644 index 0000000..d56e653 --- /dev/null +++ b/Sources/Cache/Cache/ExpiringCache.swift @@ -0,0 +1,308 @@ +import Foundation + +/** + A cache that retains and returns objects for a specific duration set by the `ExpirationDuration` enumeration. The `ExpiringCache` class conforms to the `Cacheable` protocol for common cache operations. + + - Note: The keys used in the cache must be `Hashable` conformant. + + - Warning: Using an overly long `ExpirationDuration` can cause the cache to retain more memory than necessary or reduce performance, while using an overly short `ExpirationDuration` can cause the cache to remove outdated results. + + Objects stored in the cache are automatically removed when their expiration duration has passed. + */ +public class ExpiringCache: Cacheable { + /// `Error` that reports expired values + public struct ExpiriedValueError: LocalizedError { + /// Expired key + public let key: Key + + /// When the value expired + public let expiration: Date + + /** + Initializes a new `ExpiredValueError`. + - Parameters: + - key: The expired key. + - expiration: The expiration date. + */ + public init( + key: Key, + expiration: Date + ) { + self.key = key + self.expiration = expiration + } + + /// Error description for `LocalizedError` + public var errorDescription: String? { + let dateFormatter = DateFormatter() + + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .medium + + return "Expired Key: \(key) (expired at \(dateFormatter.string(from: expiration)))" + } + } + + /** + Enumeration used to represent expiration durations in seconds, minutes or hours. + */ + public enum ExpirationDuration { + /// The enumeration cases representing a duration in seconds. + case seconds(UInt) + + /// The enumeration cases representing a duration in minutes. + case minutes(UInt) + + /// The enumeration cases representing a duration in hours. + case hours(UInt) + + /** + A computed property that returns a TimeInterval value representing + the duration calculated from the given unit and duration value. + + - Returns: A `TimeInterval` value representing the duration of the time unit set for the `ExpirationDuration`. + */ + public var timeInterval: TimeInterval { + switch self { + case let .seconds(seconds): return TimeInterval(seconds) + case let .minutes(minutes): return TimeInterval(minutes) * 60 + case let .hours(hours): return TimeInterval(hours) * 60 * 60 + } + } + } + + private struct ExpiringValue { + let expriation: Date + let value: Value + } + + /// The cache used to store the key-value pairs. + private let cache: Cache + + /// The duration before each object will be removed. The duration for each item is determined when the value is added to the cache. + public let duration: ExpirationDuration + + /// Returns a dictionary containing all the key value pairs of the cache. + public var allValues: [Key: Value] { + values(ofType: Value.self) + } + + /** + Initializes a new `ExpiringCache` instance with an optional dictionary of initial key-value pairs. + + - Parameters: + - duration: The duration before each object will be removed. The duration for each item is determined when the value is added to the Cache. + - initialValues: An optional dictionary of initial key-value pairs. + */ + public init( + duration: ExpirationDuration, + initialValues: [Key: Value] = [:] + ) { + var initialExpirationValues: [Key: ExpiringValue] = [:] + + initialValues.forEach { key, value in + initialExpirationValues[key] = ExpiringValue( + expriation: Date().addingTimeInterval(duration.timeInterval), + value: value + ) + } + + self.cache = Cache(initialValues: initialExpirationValues) + self.duration = duration + } + + /** + Initializes a new `ExpiringCache` instance with duration of 1 hour and an optional dictionary of initial key-value pairs. + + - Parameters: + - initialValues: An optional dictionary of initial key-value pairs. + */ + required public convenience init(initialValues: [Key: Value] = [:]) { + self.init(duration: .hours(1), initialValues: initialValues) + } + + /** + Gets the value for the specified key and casts it to the specified output type (if possible). + + - Parameters: + - key: the key to look up in the cache. + - as: the type to cast the value to. + - Returns: the value of the specified key casted to the output type (if possible). + */ + public func get(_ key: Key, as: Output.Type = Output.self) -> Output? { + guard let expiringValue = cache.get(key, as: ExpiringValue.self) else { + return nil + } + + if isExpired(value: expiringValue) { + cache.remove(key) + + return nil + } + + return expiringValue.value as? Output + } + + /** + Gets a value from the cache for a given key. + + - Parameters: + - key: The key to retrieve the value for. + - Returns: The value stored in cache for the given key, or `nil` if it doesn't exist. + */ + open func get(_ key: Key) -> Value? { + get(key, as: Value.self) + } + + /** + Resolves the value for the specified key and casts it to the specified output type. + + - Parameters: + - key: the key to look up in the cache. + - as: the type to cast the value to. + - Throws: InvalidTypeError if the specified key is missing or if the value cannot be casted to the specified output type. + - Returns: the value of the specified key casted to the output type. + */ + public func resolve(_ key: Key, as: Output.Type = Output.self) throws -> Output { + let expiringValue = try cache.resolve(key, as: ExpiringValue.self) + + if isExpired(value: expiringValue) { + remove(key) + + throw ExpiriedValueError( + key: key, + expiration: expiringValue.expriation + ) + } + + guard let value = expiringValue.value as? Output else { + throw InvalidTypeError( + expectedType: Output.self, + actualType: type(of: expiringValue.value) + ) + } + + return value + } + + /** + Resolves a value from the cache for a given key. + + - Parameters: + - key: The key to retrieve the value for. + - Returns: The value stored in cache for the given key. + - Throws: `MissingRequiredKeysError` if the key is missing, or `InvalidTypeError` if the value type is not compatible with the expected type. + */ + open func resolve(_ key: Key) throws -> Value { + try resolve(key, as: Value.self) + } + + /** + Sets the value for the specified key. + + - Parameters: + - value: the value to store in the cache. + - key: the key to use for storing the value in the cache. + */ + public func set(value: Value, forKey key: Key) { + cache.set( + value: ExpiringValue( + expriation: Date().addingTimeInterval(duration.timeInterval), + value: value + ), + forKey: key + ) + } + + /** + Removes the value for the specified key from the cache. + + - Parameter key: the key to remove from the cache. + */ + public func remove(_ key: Key) { + cache.remove(key) + } + + /** + Checks whether the cache contains the specified key. + + - Parameter key: the key to look up in the cache. + - Returns: true if the cache contains the key, false otherwise. + */ + public func contains(_ key: Key) -> Bool { + guard let expiringValue = cache.get(key, as: ExpiringValue.self) else { + return false + } + + if isExpired(value: expiringValue) { + remove(key) + + return false + } + + return cache.contains(key) + } + + /** + Checks whether the cache contains all the specified keys. + + - Parameter keys: the set of keys to require. + - Throws: MissingRequiredKeysError if any of the specified keys are missing from the cache. + - Returns: self (the Cache instance). + */ + public func require(keys: Set) throws -> Self { + var missingKeys: Set = [] + + for key in keys { + if contains(key) == false { + missingKeys.insert(key) + } + } + + guard missingKeys.isEmpty else { + throw MissingRequiredKeysError(keys: missingKeys) + } + + return self + } + + /** + Checks whether the cache contains the specified key. + + - Parameter key: the key to require. + - Throws: MissingRequiredKeysError if the specified key is missing from the cache. + - Returns: self (the Cache instance). + */ + public func require(_ key: Key) throws -> Self { + try require(keys: [key]) + } + + /** + Returns a dictionary containing only the key-value pairs where the value is of the specified output type. + + - Parameter ofType: the type of values to include in the dictionary (defaults to Value). + - Returns: a dictionary containing only the key-value pairs where the value is of the specified output type. + */ + public func values(ofType: Output.Type) -> [Key: Output] { + let values = cache.values(ofType: ExpiringValue.self) + + var nonExpiredValues: [Key: Output] = [:] + + values.forEach { key, expiringValue in + if + isExpired(value: expiringValue) == false, + let output = expiringValue.value as? Output + { + nonExpiredValues[key] = output + } + } + + return nonExpiredValues + } + + // MARK: - Private Helpers + + private func isExpired(value: ExpiringValue) -> Bool { + value.expriation <= Date() + } +} diff --git a/Tests/CacheTests/DictionaryTests.swift b/Tests/CacheTests/DictionaryTests.swift index d0fe0df..e78dc9f 100644 --- a/Tests/CacheTests/DictionaryTests.swift +++ b/Tests/CacheTests/DictionaryTests.swift @@ -1,10 +1,3 @@ -// -// DictionaryTests.swift -// -// -// Created by Leif on 6/9/23. -// - import XCTest final class DictionaryTests: XCTestCase { diff --git a/Tests/CacheTests/ExampleTests.swift b/Tests/CacheTests/ExampleTests.swift index a43b917..e68a6a4 100644 --- a/Tests/CacheTests/ExampleTests.swift +++ b/Tests/CacheTests/ExampleTests.swift @@ -1,10 +1,3 @@ -// -// ExampleTests.swift -// -// -// Created by Leif on 6/9/23. -// - import SwiftUI import XCTest @testable import Cache diff --git a/Tests/CacheTests/ExpiringCacheTests.swift b/Tests/CacheTests/ExpiringCacheTests.swift new file mode 100644 index 0000000..9107bad --- /dev/null +++ b/Tests/CacheTests/ExpiringCacheTests.swift @@ -0,0 +1,378 @@ +import XCTest +@testable import Cache + +final class ExpiringCacheTests: XCTestCase { + func testAllValues() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache.allValues.count, 1) + } + + func testGet_Success() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache.get(.text), "Hello, World!") + } + + func testGet_MissingKey() { + enum Key { + case text + case missingKey + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertNil(cache.get(.missingKey)) + } + + func testGet_InvalidType() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertNil(cache.get(.text, as: Int.self)) + } + + func testResolve_Success() throws { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(try cache.resolve(.text), "Hello, World!") + } + + func testResolve_MissingKey() throws { + enum Key { + case text + case missingKey + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertThrowsError(try cache.resolve(.missingKey)) + } + + func testResolve_InvalidType() throws { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertThrowsError(try cache.resolve(.text, as: Int.self)) + } + + func testSet() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertEqual(cache.get(.text), "Hello, World!") + } + + func testRemove() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache.get(.text), "Hello, World!") + + cache.remove(.text) + + XCTAssertNil(cache.get(.text)) + } + + func testContains() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssert(cache.contains(.text)) + } + + func testRequire_Success() throws { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertNoThrow(try cache.require(.text)) + } + + func testRequire_Missing() throws { + enum Key { + case text + case missingKey + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertThrowsError(try cache.require(.missingKey)) + } + + func testRequireSet_Success() throws { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertNoThrow(try cache.require(keys: [.text])) + } + + func testRequireSet_Missing() throws { + enum Key { + case text + case missingKey + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertThrowsError(try cache.require(keys: [.text, .missingKey])) + } + + func testSubscript_Get() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache[.text], "Hello, World!") + } + + func testSubscript_Set() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + cache[.text] = "Hello, World!" + + XCTAssertEqual(cache[.text], "Hello, World!") + } + + func testSubscript_SetNil() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + cache[.text] = nil + + XCTAssertNil(cache[.text]) + } + + func testSubscriptDefault_GetSuccess() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache( + initialValues: [ + .text: "Hello, World!" + ] + ) + + XCTAssertEqual(cache[.text, default: "missing value"], "Hello, World!") + } + + func testSubscriptDefault_GetFailure() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + XCTAssertEqual(cache[.text, default: "missing value"], "missing value") + } + + func testSubscriptDefault_SetSuccess() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + cache[.text, default: ""] = "Hello, World!" + + XCTAssertEqual(cache[.text, default: "missing value"], "Hello, World!") + } + + func testSubscriptDefault_SetFailure() { + enum Key { + case text + } + + let cache: ExpiringCache = ExpiringCache() + + XCTAssertEqual(cache[.text, default: "missing value"], "missing value") + } + + func testInstantExpiration_get() { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(0)) + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertNil(cache.get(.text)) + } + + func testInstantExpiration_resolve() { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(0)) + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertThrowsError(try cache.resolve(.text)) + } + + func testInstantExpiration_contains() { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(0)) + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertFalse(cache.contains(.text)) + } + + func testExpiration_error() throws { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(0)) + + cache.set(value: "Hello, World!", forKey: .text) + + do { + _ = try cache.resolve(.text) + + XCTFail("resolve should throw") + } catch { + XCTAssert( + error.localizedDescription.contains("Expired Key: text") + ) + } + } + + func testExpiration_secondSuccess() { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(1)) + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertEqual(cache.get(.text), "Hello, World!") + + sleep(1) + + XCTAssertNil(cache.get(.text)) + } + + func testExpiration_secondFailure() { + enum Key { + case text + } + + let cache = ExpiringCache(duration: .seconds(1)) + + cache.set(value: "Hello, World!", forKey: .text) + + XCTAssertEqual(cache.get(.text), "Hello, World!") + + usleep(500_000) + + XCTAssertNotNil(cache.get(.text)) + } +} diff --git a/Tests/CacheTests/JSONTests.swift b/Tests/CacheTests/JSONTests.swift index d4a947a..31a2773 100644 --- a/Tests/CacheTests/JSONTests.swift +++ b/Tests/CacheTests/JSONTests.swift @@ -1,10 +1,3 @@ -// -// JSONTests.swift -// -// -// Created by Leif on 6/9/23. -// - import XCTest @testable import Cache