22
33namespace Lkrms \Store ;
44
5+ use Lkrms \Exception \AssertionFailedException ;
56use Lkrms \Store \Concept \SqliteStore ;
7+ use Lkrms \Utility \Assert ;
68use DateTimeInterface ;
79use InvalidArgumentException ;
810use 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 */
1318final 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}
0 commit comments