Skip to content

Commit b0823f6

Browse files
committed
Merge branch 'review-cache'
2 parents 4836d2c + 0dff1d2 commit b0823f6

File tree

4 files changed

+205
-57
lines changed

4 files changed

+205
-57
lines changed

src/Facade/Cache.php

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Store/CacheStore.php

Lines changed: 148 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22

33
namespace Lkrms\Store;
44

5+
use Lkrms\Exception\AssertionFailedException;
56
use Lkrms\Store\Concept\SqliteStore;
7+
use Lkrms\Utility\Assert;
68
use DateTimeInterface;
79
use InvalidArgumentException;
810
use LogicException;
911

1012
/**
1113
* A SQLite-backed key-value store
14+
*
15+
* Expired items are not implicitly flushed. {@see CacheStore::flush()} must be
16+
* called explicitly, e.g. on a schedule or once per run.
1217
*/
1318
final class CacheStore extends SqliteStore
1419
{
@@ -114,30 +119,12 @@ public function set(string $key, $value, $expires = null)
114119
*/
115120
public function has(string $key, ?int $maxAge = null): bool
116121
{
117-
$where[] = 'item_key = :item_key';
118-
$bind[] = [':item_key', $key, \SQLITE3_TEXT];
119-
120-
$bindNow = false;
121-
if ($maxAge === null) {
122-
$where[] = "(expires_at IS NULL OR expires_at > DATETIME(:now, 'unixepoch'))";
123-
$bindNow = true;
124-
} elseif ($maxAge) {
125-
$where[] = "DATETIME(set_at, :max_age) > DATETIME(:now, 'unixepoch')";
126-
$bind[] = [':max_age', "+$maxAge seconds", \SQLITE3_TEXT];
127-
$bindNow = true;
128-
}
129-
if ($bindNow) {
130-
$bind[] = [':now', $this->now(), \SQLITE3_INTEGER];
131-
}
132-
133-
$where = implode(' AND ', $where);
122+
$where = $this->getWhere($key, $maxAge, $bind);
134123
$sql = <<<SQL
135124
SELECT
136125
COUNT(*)
137126
FROM
138-
_cache_item
139-
WHERE
140-
$where
127+
_cache_item $where
141128
SQL;
142129
$db = $this->db();
143130
$stmt = $db->prepare($sql);
@@ -164,30 +151,12 @@ public function has(string $key, ?int $maxAge = null): bool
164151
*/
165152
public function get(string $key, ?int $maxAge = null)
166153
{
167-
$where[] = 'item_key = :item_key';
168-
$bind[] = [':item_key', $key, \SQLITE3_TEXT];
169-
170-
$bindNow = false;
171-
if ($maxAge === null) {
172-
$where[] = "(expires_at IS NULL OR expires_at > DATETIME(:now, 'unixepoch'))";
173-
$bindNow = true;
174-
} elseif ($maxAge) {
175-
$where[] = "DATETIME(set_at, :max_age) > DATETIME(:now, 'unixepoch')";
176-
$bind[] = [':max_age', "+$maxAge seconds", \SQLITE3_TEXT];
177-
$bindNow = true;
178-
}
179-
if ($bindNow) {
180-
$bind[] = [':now', $this->now(), \SQLITE3_INTEGER];
181-
}
182-
183-
$where = implode(' AND ', $where);
154+
$where = $this->getWhere($key, $maxAge, $bind);
184155
$sql = <<<SQL
185156
SELECT
186157
item_value
187158
FROM
188-
_cache_item
189-
WHERE
190-
$where
159+
_cache_item $where
191160
SQL;
192161
$db = $this->db();
193162
$stmt = $db->prepare($sql);
@@ -204,6 +173,32 @@ public function get(string $key, ?int $maxAge = null)
204173
return unserialize($row[0]);
205174
}
206175

176+
/**
177+
* Retrieve an instance of a class stored under a given key
178+
*
179+
* If `$maxAge` is `null` (the default), the item's expiration time is
180+
* honoured, otherwise it is ignored and the item is considered fresh if:
181+
*
182+
* - its age in seconds is less than or equal to `$maxAge`, or
183+
* - `$maxAge` is `0`
184+
*
185+
* @template T
186+
*
187+
* @param class-string<T> $class
188+
* @return T|false `false` if the item has expired or doesn't exist.
189+
* @throws AssertionFailedException if the item stored under `$key` is not
190+
* an instance of `$class`.
191+
*/
192+
public function getInstanceOf(string $key, string $class, ?int $maxAge = null)
193+
{
194+
$item = $this->get($key, $maxAge);
195+
if ($item === false) {
196+
return false;
197+
}
198+
Assert::instanceOf($item, $class);
199+
return $item;
200+
}
201+
207202
/**
208203
* Delete an item stored under a given key
209204
*
@@ -213,8 +208,7 @@ public function delete(string $key)
213208
{
214209
$db = $this->db();
215210
$sql = <<<SQL
216-
DELETE FROM
217-
_cache_item
211+
DELETE FROM _cache_item
218212
WHERE
219213
item_key = :item_key;
220214
SQL;
@@ -236,8 +230,7 @@ public function deleteAll()
236230
$db = $this->db();
237231
$db->exec(
238232
<<<SQL
239-
DELETE FROM
240-
_cache_item;
233+
DELETE FROM _cache_item;
241234
SQL
242235
);
243236

@@ -251,15 +244,16 @@ public function deleteAll()
251244
*/
252245
public function flush()
253246
{
254-
$db = $this->db();
255-
$db->exec(
256-
<<<SQL
257-
DELETE FROM
258-
_cache_item
247+
$sql = <<<SQL
248+
DELETE FROM _cache_item
259249
WHERE
260-
expires_at <= CURRENT_TIMESTAMP;
261-
SQL
262-
);
250+
expires_at <= DATETIME(:now, 'unixepoch');
251+
SQL;
252+
$db = $this->db();
253+
$stmt = $db->prepare($sql);
254+
$stmt->bindValue(':now', $this->now(), \SQLITE3_INTEGER);
255+
$stmt->execute();
256+
$stmt->close();
263257

264258
return $this;
265259
}
@@ -268,12 +262,14 @@ public function flush()
268262
* Retrieve an item stored under a given key, or get it from a callback and
269263
* store it for subsequent retrieval
270264
*
271-
* @param callable(): mixed $callback
265+
* @template T
266+
*
267+
* @param callable(): T $callback
272268
* @param DateTimeInterface|int|null $expires `null` or `0` if the value
273269
* should be cached indefinitely, otherwise a {@see DateTimeInterface} or
274270
* Unix timestamp representing its expiration time, or an integer
275271
* representing its lifetime in seconds.
276-
* @return mixed
272+
* @return T
277273
*/
278274
public function maybeGet(string $key, callable $callback, $expires = null)
279275
{
@@ -292,6 +288,71 @@ public function maybeGet(string $key, callable $callback, $expires = null)
292288
return $value;
293289
}
294290

291+
/**
292+
* Get the number of unexpired items in the store
293+
*
294+
* If `$maxAge` is `null` (the default), each item's expiration time is
295+
* honoured, otherwise it is ignored and items are considered fresh if:
296+
*
297+
* - their age in seconds is less than or equal to `$maxAge`, or
298+
* - `$maxAge` is `0`
299+
*/
300+
public function getItemCount(?int $maxAge = null): int
301+
{
302+
$where = $this->getWhere(null, $maxAge, $bind);
303+
$sql = <<<SQL
304+
SELECT
305+
COUNT(*)
306+
FROM
307+
_cache_item $where
308+
SQL;
309+
$db = $this->db();
310+
$stmt = $db->prepare($sql);
311+
foreach ($bind as $param) {
312+
$stmt->bindValue(...$param);
313+
}
314+
$result = $stmt->execute();
315+
$row = $result->fetchArray(\SQLITE3_NUM);
316+
$stmt->close();
317+
318+
return $row[0];
319+
}
320+
321+
/**
322+
* Get a list of keys under which unexpired items are stored
323+
*
324+
* If `$maxAge` is `null` (the default), each item's expiration time is
325+
* honoured, otherwise it is ignored and items are considered fresh if:
326+
*
327+
* - their age in seconds is less than or equal to `$maxAge`, or
328+
* - `$maxAge` is `0`
329+
*
330+
* @return string[]
331+
*/
332+
public function getAllKeys(?int $maxAge = null): array
333+
{
334+
$where = $this->getWhere(null, $maxAge, $bind);
335+
$sql = <<<SQL
336+
SELECT
337+
item_key
338+
FROM
339+
_cache_item $where
340+
SQL;
341+
$db = $this->db();
342+
$stmt = $db->prepare($sql);
343+
foreach ($bind as $param) {
344+
$stmt->bindValue(...$param);
345+
}
346+
$result = $stmt->execute();
347+
while (($row = $result->fetchArray(\SQLITE3_NUM)) !== false) {
348+
$keys[] = $row[0];
349+
}
350+
$result->finalize();
351+
$stmt->close();
352+
353+
return $keys ?? [];
354+
}
355+
295356
/**
296357
* Get a copy of the store where items do not expire over time
297358
*
@@ -327,4 +388,37 @@ private function now(): int
327388
? time()
328389
: $this->Now;
329390
}
391+
392+
/**
393+
* @param array<string,mixed> $bind
394+
*/
395+
private function getWhere(?string $key, ?int $maxAge, ?array &$bind): string
396+
{
397+
$where = [];
398+
$bind = [];
399+
400+
if ($key !== null) {
401+
$where[] = 'item_key = :item_key';
402+
$bind[] = [':item_key', $key, \SQLITE3_TEXT];
403+
}
404+
405+
$bindNow = false;
406+
if ($maxAge === null) {
407+
$where[] = "(expires_at IS NULL OR expires_at > DATETIME(:now, 'unixepoch'))";
408+
$bindNow = true;
409+
} elseif ($maxAge) {
410+
$where[] = "DATETIME(set_at, :max_age) > DATETIME(:now, 'unixepoch')";
411+
$bind[] = [':max_age', "+$maxAge seconds", \SQLITE3_TEXT];
412+
$bindNow = true;
413+
}
414+
if ($bindNow) {
415+
$bind[] = [':now', $this->now(), \SQLITE3_INTEGER];
416+
}
417+
418+
$where = implode(' AND ', $where);
419+
if ($where === '') {
420+
return '';
421+
}
422+
return "WHERE $where";
423+
}
330424
}

src/Utility/Assert.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ public static function notEmpty($value, ?string $name = null): void
7373
}
7474
}
7575

76+
/**
77+
* Assert that a value is an instance of a class or interface
78+
*
79+
* @param mixed $value
80+
* @param class-string $class
81+
*/
82+
public static function instanceOf($value, string $class, ?string $name = null): void
83+
{
84+
if (!is_a($value, $class)) {
85+
self::throwException(sprintf('{} must be an instance of %s', $class), $name);
86+
}
87+
}
88+
7689
public static function patternMatches(?string $value, string $pattern, ?string $name = null): void
7790
{
7891
if (is_null($value) || !preg_match($pattern, $value)) {

0 commit comments

Comments
 (0)