Skip to content

Commit 6ddb9ec

Browse files
authored
Add new ExpirationCache (#1)
* Add new ExpirationCache * Reduce flacky test
1 parent 619fdbf commit 6ddb9ec

File tree

7 files changed

+748
-21
lines changed

7 files changed

+748
-21
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ You can also just set the value to `nil` using the subscripts
8484
cache[.text] = nil
8585
```
8686

87+
### ExpiringCache
88+
89+
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.
90+
91+
#### Usage
92+
93+
```swift
94+
// Create an instance of the cache with a duration of 5 minutes
95+
let cache = ExpiringCache<String, Int>(duration: .minutes(5))
96+
97+
// Store a value in the cache with a key
98+
cache["Answer"] = 42
99+
100+
// Retrieve a value from the cache using its key
101+
if let answer = cache["Answer"] {
102+
print("The answer is \(answer)")
103+
}
104+
```
105+
106+
#### Expiration Duration
107+
108+
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.
109+
87110
### Advanced Usage
88111

89112
You can use `Cache` as an observed object:
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
extension ExpiringCache {
2+
/**
3+
Accesses the value associated with the given key for reading and writing.
4+
5+
- Parameters:
6+
- key: The key to retrieve the value for.
7+
- Returns: The value stored in the cache for the given key, or `nil` if it doesn't exist.
8+
- Notes: If `nil` is assigned to the subscript, then the key-value pair is removed from the cache.
9+
*/
10+
public subscript(_ key: Key) -> Value? {
11+
get {
12+
get(key, as: Value.self)
13+
}
14+
set(newValue) {
15+
guard let newValue else {
16+
return remove(key)
17+
}
18+
19+
set(value: newValue, forKey: key)
20+
}
21+
}
22+
23+
/**
24+
Accesses the value associated with the given key for reading and writing, optionally using a default value if the key is missing.
25+
26+
- Parameters:
27+
- key: The key to retrieve the value for.
28+
- default: The default value to be returned if the key is missing.
29+
- Returns: The value stored in the cache for the given key, or the default value if it doesn't exist.
30+
*/
31+
public subscript(_ key: Key, default value: Value) -> Value {
32+
get {
33+
get(key, as: Value.self) ?? value
34+
}
35+
set(newValue) {
36+
set(value: newValue, forKey: key)
37+
}
38+
}
39+
}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import Foundation
2+
3+
/**
4+
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.
5+
6+
- Note: The keys used in the cache must be `Hashable` conformant.
7+
8+
- 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.
9+
10+
Objects stored in the cache are automatically removed when their expiration duration has passed.
11+
*/
12+
public class ExpiringCache<Key: Hashable, Value>: Cacheable {
13+
/// `Error` that reports expired values
14+
public struct ExpiriedValueError<Key: Hashable>: LocalizedError {
15+
/// Expired key
16+
public let key: Key
17+
18+
/// When the value expired
19+
public let expiration: Date
20+
21+
/**
22+
Initializes a new `ExpiredValueError`.
23+
- Parameters:
24+
- key: The expired key.
25+
- expiration: The expiration date.
26+
*/
27+
public init(
28+
key: Key,
29+
expiration: Date
30+
) {
31+
self.key = key
32+
self.expiration = expiration
33+
}
34+
35+
/// Error description for `LocalizedError`
36+
public var errorDescription: String? {
37+
let dateFormatter = DateFormatter()
38+
39+
dateFormatter.dateStyle = .short
40+
dateFormatter.timeStyle = .medium
41+
42+
return "Expired Key: \(key) (expired at \(dateFormatter.string(from: expiration)))"
43+
}
44+
}
45+
46+
/**
47+
Enumeration used to represent expiration durations in seconds, minutes or hours.
48+
*/
49+
public enum ExpirationDuration {
50+
/// The enumeration cases representing a duration in seconds.
51+
case seconds(UInt)
52+
53+
/// The enumeration cases representing a duration in minutes.
54+
case minutes(UInt)
55+
56+
/// The enumeration cases representing a duration in hours.
57+
case hours(UInt)
58+
59+
/**
60+
A computed property that returns a TimeInterval value representing
61+
the duration calculated from the given unit and duration value.
62+
63+
- Returns: A `TimeInterval` value representing the duration of the time unit set for the `ExpirationDuration`.
64+
*/
65+
public var timeInterval: TimeInterval {
66+
switch self {
67+
case let .seconds(seconds): return TimeInterval(seconds)
68+
case let .minutes(minutes): return TimeInterval(minutes) * 60
69+
case let .hours(hours): return TimeInterval(hours) * 60 * 60
70+
}
71+
}
72+
}
73+
74+
private struct ExpiringValue {
75+
let expriation: Date
76+
let value: Value
77+
}
78+
79+
/// The cache used to store the key-value pairs.
80+
private let cache: Cache<Key, ExpiringValue>
81+
82+
/// The duration before each object will be removed. The duration for each item is determined when the value is added to the cache.
83+
public let duration: ExpirationDuration
84+
85+
/// Returns a dictionary containing all the key value pairs of the cache.
86+
public var allValues: [Key: Value] {
87+
values(ofType: Value.self)
88+
}
89+
90+
/**
91+
Initializes a new `ExpiringCache` instance with an optional dictionary of initial key-value pairs.
92+
93+
- Parameters:
94+
- duration: The duration before each object will be removed. The duration for each item is determined when the value is added to the Cache.
95+
- initialValues: An optional dictionary of initial key-value pairs.
96+
*/
97+
public init(
98+
duration: ExpirationDuration,
99+
initialValues: [Key: Value] = [:]
100+
) {
101+
var initialExpirationValues: [Key: ExpiringValue] = [:]
102+
103+
initialValues.forEach { key, value in
104+
initialExpirationValues[key] = ExpiringValue(
105+
expriation: Date().addingTimeInterval(duration.timeInterval),
106+
value: value
107+
)
108+
}
109+
110+
self.cache = Cache(initialValues: initialExpirationValues)
111+
self.duration = duration
112+
}
113+
114+
/**
115+
Initializes a new `ExpiringCache` instance with duration of 1 hour and an optional dictionary of initial key-value pairs.
116+
117+
- Parameters:
118+
- initialValues: An optional dictionary of initial key-value pairs.
119+
*/
120+
required public convenience init(initialValues: [Key: Value] = [:]) {
121+
self.init(duration: .hours(1), initialValues: initialValues)
122+
}
123+
124+
/**
125+
Gets the value for the specified key and casts it to the specified output type (if possible).
126+
127+
- Parameters:
128+
- key: the key to look up in the cache.
129+
- as: the type to cast the value to.
130+
- Returns: the value of the specified key casted to the output type (if possible).
131+
*/
132+
public func get<Output>(_ key: Key, as: Output.Type = Output.self) -> Output? {
133+
guard let expiringValue = cache.get(key, as: ExpiringValue.self) else {
134+
return nil
135+
}
136+
137+
if isExpired(value: expiringValue) {
138+
cache.remove(key)
139+
140+
return nil
141+
}
142+
143+
return expiringValue.value as? Output
144+
}
145+
146+
/**
147+
Gets a value from the cache for a given key.
148+
149+
- Parameters:
150+
- key: The key to retrieve the value for.
151+
- Returns: The value stored in cache for the given key, or `nil` if it doesn't exist.
152+
*/
153+
open func get(_ key: Key) -> Value? {
154+
get(key, as: Value.self)
155+
}
156+
157+
/**
158+
Resolves the value for the specified key and casts it to the specified output type.
159+
160+
- Parameters:
161+
- key: the key to look up in the cache.
162+
- as: the type to cast the value to.
163+
- Throws: InvalidTypeError if the specified key is missing or if the value cannot be casted to the specified output type.
164+
- Returns: the value of the specified key casted to the output type.
165+
*/
166+
public func resolve<Output>(_ key: Key, as: Output.Type = Output.self) throws -> Output {
167+
let expiringValue = try cache.resolve(key, as: ExpiringValue.self)
168+
169+
if isExpired(value: expiringValue) {
170+
remove(key)
171+
172+
throw ExpiriedValueError(
173+
key: key,
174+
expiration: expiringValue.expriation
175+
)
176+
}
177+
178+
guard let value = expiringValue.value as? Output else {
179+
throw InvalidTypeError(
180+
expectedType: Output.self,
181+
actualType: type(of: expiringValue.value)
182+
)
183+
}
184+
185+
return value
186+
}
187+
188+
/**
189+
Resolves a value from the cache for a given key.
190+
191+
- Parameters:
192+
- key: The key to retrieve the value for.
193+
- Returns: The value stored in cache for the given key.
194+
- Throws: `MissingRequiredKeysError` if the key is missing, or `InvalidTypeError` if the value type is not compatible with the expected type.
195+
*/
196+
open func resolve(_ key: Key) throws -> Value {
197+
try resolve(key, as: Value.self)
198+
}
199+
200+
/**
201+
Sets the value for the specified key.
202+
203+
- Parameters:
204+
- value: the value to store in the cache.
205+
- key: the key to use for storing the value in the cache.
206+
*/
207+
public func set(value: Value, forKey key: Key) {
208+
cache.set(
209+
value: ExpiringValue(
210+
expriation: Date().addingTimeInterval(duration.timeInterval),
211+
value: value
212+
),
213+
forKey: key
214+
)
215+
}
216+
217+
/**
218+
Removes the value for the specified key from the cache.
219+
220+
- Parameter key: the key to remove from the cache.
221+
*/
222+
public func remove(_ key: Key) {
223+
cache.remove(key)
224+
}
225+
226+
/**
227+
Checks whether the cache contains the specified key.
228+
229+
- Parameter key: the key to look up in the cache.
230+
- Returns: true if the cache contains the key, false otherwise.
231+
*/
232+
public func contains(_ key: Key) -> Bool {
233+
guard let expiringValue = cache.get(key, as: ExpiringValue.self) else {
234+
return false
235+
}
236+
237+
if isExpired(value: expiringValue) {
238+
remove(key)
239+
240+
return false
241+
}
242+
243+
return cache.contains(key)
244+
}
245+
246+
/**
247+
Checks whether the cache contains all the specified keys.
248+
249+
- Parameter keys: the set of keys to require.
250+
- Throws: MissingRequiredKeysError if any of the specified keys are missing from the cache.
251+
- Returns: self (the Cache instance).
252+
*/
253+
public func require(keys: Set<Key>) throws -> Self {
254+
var missingKeys: Set<Key> = []
255+
256+
for key in keys {
257+
if contains(key) == false {
258+
missingKeys.insert(key)
259+
}
260+
}
261+
262+
guard missingKeys.isEmpty else {
263+
throw MissingRequiredKeysError(keys: missingKeys)
264+
}
265+
266+
return self
267+
}
268+
269+
/**
270+
Checks whether the cache contains the specified key.
271+
272+
- Parameter key: the key to require.
273+
- Throws: MissingRequiredKeysError if the specified key is missing from the cache.
274+
- Returns: self (the Cache instance).
275+
*/
276+
public func require(_ key: Key) throws -> Self {
277+
try require(keys: [key])
278+
}
279+
280+
/**
281+
Returns a dictionary containing only the key-value pairs where the value is of the specified output type.
282+
283+
- Parameter ofType: the type of values to include in the dictionary (defaults to Value).
284+
- Returns: a dictionary containing only the key-value pairs where the value is of the specified output type.
285+
*/
286+
public func values<Output>(ofType: Output.Type) -> [Key: Output] {
287+
let values = cache.values(ofType: ExpiringValue.self)
288+
289+
var nonExpiredValues: [Key: Output] = [:]
290+
291+
values.forEach { key, expiringValue in
292+
if
293+
isExpired(value: expiringValue) == false,
294+
let output = expiringValue.value as? Output
295+
{
296+
nonExpiredValues[key] = output
297+
}
298+
}
299+
300+
return nonExpiredValues
301+
}
302+
303+
// MARK: - Private Helpers
304+
305+
private func isExpired(value: ExpiringValue) -> Bool {
306+
value.expriation <= Date()
307+
}
308+
}

Tests/CacheTests/DictionaryTests.swift

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
//
2-
// DictionaryTests.swift
3-
//
4-
//
5-
// Created by Leif on 6/9/23.
6-
//
7-
81
import XCTest
92

103
final class DictionaryTests: XCTestCase {

0 commit comments

Comments
 (0)