Skip to content

Commit

Permalink
Add new ExpirationCache (#1)
Browse files Browse the repository at this point in the history
* Add new ExpirationCache

* Reduce flacky test
  • Loading branch information
0xLeif committed Jun 9, 2023
1 parent 619fdbf commit 6ddb9ec
Show file tree
Hide file tree
Showing 7 changed files with 748 additions and 21 deletions.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Int>(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:
Expand Down
39 changes: 39 additions & 0 deletions Sources/Cache/Cache/ExpiringCache+subscript.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
308 changes: 308 additions & 0 deletions Sources/Cache/Cache/ExpiringCache.swift
Original file line number Diff line number Diff line change
@@ -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<Key: Hashable, Value>: Cacheable {
/// `Error` that reports expired values
public struct ExpiriedValueError<Key: Hashable>: 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<Key, ExpiringValue>

/// 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<Output>(_ 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<Output>(_ 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<Key>) throws -> Self {
var missingKeys: Set<Key> = []

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<Output>(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()
}
}
7 changes: 0 additions & 7 deletions Tests/CacheTests/DictionaryTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
//
// DictionaryTests.swift
//
//
// Created by Leif on 6/9/23.
//

import XCTest

final class DictionaryTests: XCTestCase {
Expand Down

0 comments on commit 6ddb9ec

Please sign in to comment.