1: <?php declare(strict_types=1);
2:
3: namespace Salient\Cache;
4:
5: use Salient\Contract\Cache\CacheInterface;
6: use Salient\Core\AbstractStore;
7: use DateInterval;
8: use DateTimeImmutable;
9: use DateTimeInterface;
10: use LogicException;
11: use SQLite3Result;
12: use SQLite3Stmt;
13:
14: /**
15: * A PSR-16 key-value store backed by a SQLite database
16: *
17: * @api
18: */
19: final class CacheStore extends AbstractStore implements CacheInterface
20: {
21: private ?SQLite3Stmt $Stmt = null;
22: private ?int $Now = null;
23:
24: /**
25: * Creates a new CacheStore object
26: */
27: public function __construct(string $filename = ':memory:')
28: {
29: $this->assertCanUpsert();
30:
31: $this->openDb(
32: $filename,
33: <<<SQL
34: CREATE TABLE IF NOT EXISTS
35: _cache_item (
36: item_key TEXT NOT NULL PRIMARY KEY,
37: item_value BLOB,
38: expires_at DATETIME,
39: added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
40: set_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
41: ) WITHOUT ROWID;
42:
43: CREATE TRIGGER IF NOT EXISTS _cache_item_update AFTER
44: UPDATE ON _cache_item BEGIN
45: UPDATE _cache_item
46: SET
47: set_at = CURRENT_TIMESTAMP
48: WHERE
49: item_key = NEW.item_key;
50: END;
51: SQL
52: );
53: }
54:
55: /**
56: * @internal
57: */
58: protected function __clone() {}
59:
60: /**
61: * @inheritDoc
62: */
63: public function set($key, $value, $ttl = null): bool
64: {
65: if ($ttl === null) {
66: $expires = null;
67: } elseif ($ttl instanceof DateInterval) {
68: $expires = (new DateTimeImmutable())->add($ttl)->getTimestamp();
69: } elseif ($ttl instanceof DateTimeInterface) {
70: $expires = $ttl->getTimestamp();
71: } elseif ($ttl > 0) {
72: $expires = time() + $ttl;
73: } else {
74: return $this->delete($key);
75: }
76:
77: $sql = <<<SQL
78: INSERT INTO
79: _cache_item (item_key, item_value, expires_at)
80: VALUES
81: (
82: :item_key,
83: :item_value,
84: DATETIME(:expires_at, 'unixepoch')
85: )
86: ON CONFLICT (item_key) DO
87: UPDATE
88: SET
89: item_value = excluded.item_value,
90: expires_at = excluded.expires_at
91: WHERE
92: item_value IS NOT excluded.item_value
93: OR expires_at IS NOT excluded.expires_at;
94: SQL;
95: $stmt = $this->prepare($sql);
96: $stmt->bindValue(':item_key', $key, \SQLITE3_TEXT);
97: $stmt->bindValue(':item_value', serialize($value), \SQLITE3_BLOB);
98: $stmt->bindValue(':expires_at', $expires, \SQLITE3_INTEGER);
99: $stmt->execute();
100: $stmt->close();
101:
102: return true;
103: }
104:
105: /**
106: * @phpstan-impure
107: */
108: public function has($key): bool
109: {
110: $sql = <<<SQL
111: SELECT
112: COUNT(*)
113: FROM
114: _cache_item
115: SQL;
116: $result = $this->queryItems($sql, $key);
117: /** @var array{int} */
118: $row = $result->fetchArray(\SQLITE3_NUM);
119: $this->closeStmt();
120:
121: return (bool) $row[0];
122: }
123:
124: /**
125: * @phpstan-impure
126: */
127: public function get($key, $default = null)
128: {
129: $sql = <<<SQL
130: SELECT
131: item_value
132: FROM
133: _cache_item
134: SQL;
135: $result = $this->queryItems($sql, $key);
136: $row = $result->fetchArray(\SQLITE3_NUM);
137: $this->closeStmt();
138:
139: return $row === false
140: ? $default
141: : unserialize($row[0]);
142: }
143:
144: /**
145: * @phpstan-impure
146: */
147: public function getInstanceOf($key, string $class, ?object $default = null): ?object
148: {
149: $item = $this->get($key);
150: if ($item === null || !is_object($item) || !is_a($item, $class)) {
151: return $default;
152: }
153: return $item;
154: }
155:
156: /**
157: * @phpstan-impure
158: */
159: public function getArray($key, ?array $default = null): ?array
160: {
161: $item = $this->get($key);
162: if ($item === null || !is_array($item)) {
163: return $default;
164: }
165: return $item;
166: }
167:
168: /**
169: * @phpstan-impure
170: */
171: public function getInt($key, ?int $default = null): ?int
172: {
173: $item = $this->get($key);
174: if ($item === null || !is_int($item)) {
175: return $default;
176: }
177: return $item;
178: }
179:
180: /**
181: * @phpstan-impure
182: */
183: public function getString($key, ?string $default = null): ?string
184: {
185: $item = $this->get($key);
186: if ($item === null || !is_string($item)) {
187: return $default;
188: }
189: return $item;
190: }
191:
192: /**
193: * @inheritDoc
194: */
195: public function delete($key): bool
196: {
197: $sql = <<<SQL
198: DELETE FROM _cache_item
199: WHERE
200: item_key = :item_key;
201: SQL;
202: $stmt = $this->prepare($sql);
203: $stmt->bindValue(':item_key', $key, \SQLITE3_TEXT);
204: $stmt->execute();
205: $stmt->close();
206:
207: return true;
208: }
209:
210: /**
211: * @inheritDoc
212: */
213: public function clear(): bool
214: {
215: $this->db()->exec(
216: <<<SQL
217: DELETE FROM _cache_item;
218: SQL
219: );
220:
221: return true;
222: }
223:
224: /**
225: * Delete expired items
226: *
227: * @return true
228: */
229: public function clearExpired(): bool
230: {
231: $sql = <<<SQL
232: DELETE FROM _cache_item
233: WHERE
234: expires_at <= DATETIME(:now, 'unixepoch');
235: SQL;
236: $stmt = $this->prepare($sql);
237: $stmt->bindValue(':now', $this->now(), \SQLITE3_INTEGER);
238: $stmt->execute();
239: $stmt->close();
240:
241: return true;
242: }
243:
244: /**
245: * @inheritDoc
246: */
247: public function getMultiple($keys, $default = null)
248: {
249: foreach ($keys as $key) {
250: $values[$key] = $this->get($key, $default);
251: }
252: return $values ?? [];
253: }
254:
255: /**
256: * @inheritDoc
257: */
258: public function setMultiple($values, $ttl = null): bool
259: {
260: foreach ($values as $key => $value) {
261: $this->set($key, $value, $ttl);
262: }
263: return true;
264: }
265:
266: /**
267: * @inheritDoc
268: */
269: public function deleteMultiple($keys): bool
270: {
271: foreach ($keys as $key) {
272: $this->delete($key);
273: }
274: return true;
275: }
276:
277: /**
278: * @phpstan-impure
279: */
280: public function getItemCount(): int
281: {
282: $sql = <<<SQL
283: SELECT
284: COUNT(*)
285: FROM
286: _cache_item
287: SQL;
288: $result = $this->queryItems($sql);
289: /** @var array{int} */
290: $row = $result->fetchArray(\SQLITE3_NUM);
291: $this->closeStmt();
292:
293: return $row[0];
294: }
295:
296: /**
297: * @phpstan-impure
298: */
299: public function getItemKeys(): array
300: {
301: $sql = <<<SQL
302: SELECT
303: item_key
304: FROM
305: _cache_item
306: SQL;
307: $result = $this->queryItems($sql);
308: while (($row = $result->fetchArray(\SQLITE3_NUM)) !== false) {
309: $keys[] = $row[0];
310: }
311: $this->closeStmt();
312:
313: return $keys ?? [];
314: }
315:
316: /**
317: * @inheritDoc
318: */
319: public function asOfNow(?int $now = null): CacheInterface
320: {
321: if ($this->Now !== null) {
322: throw new LogicException(
323: sprintf('Calls to %s() cannot be nested', __METHOD__)
324: );
325: }
326:
327: if ($this->hasTransaction()) {
328: throw new LogicException(sprintf(
329: '%s() cannot be called until the instance returned previously is closed or discarded',
330: __METHOD__,
331: ));
332: }
333:
334: $clone = clone $this;
335: $clone->Now = $now ?? time();
336: return $clone->beginTransaction();
337: }
338:
339: /**
340: * @inheritDoc
341: */
342: public function close(): void
343: {
344: if (!$this->isOpen()) {
345: $this->closeDb();
346: return;
347: }
348:
349: if ($this->Now !== null) {
350: $this->commitTransaction()->detachDb();
351: return;
352: }
353:
354: $this->clearExpired();
355: $this->closeDb();
356: }
357:
358: /**
359: * @internal
360: */
361: public function __destruct()
362: {
363: $this->close();
364: }
365:
366: private function now(): int
367: {
368: return $this->Now ?? time();
369: }
370:
371: private function queryItems(string $sql, ?string $key = null): SQLite3Result
372: {
373: if ($key !== null) {
374: $where[] = 'item_key = :item_key';
375: $bind[] = [':item_key', $key, \SQLITE3_TEXT];
376: }
377:
378: $where[] = "(expires_at IS NULL OR expires_at > DATETIME(:now, 'unixepoch'))";
379: $bind[] = [':now', $this->now(), \SQLITE3_INTEGER];
380:
381: $where = implode(' AND ', $where);
382: $sql .= " WHERE $where";
383:
384: $stmt = $this->prepare($sql);
385: foreach ($bind as [$param, $value, $type]) {
386: $stmt->bindValue($param, $value, $type);
387: }
388:
389: $result = $this->execute($stmt);
390: $this->Stmt = $stmt;
391: return $result;
392: }
393:
394: private function closeStmt(): void
395: {
396: if ($this->Stmt !== null) {
397: $this->Stmt->close();
398: $this->Stmt = null;
399: }
400: }
401: }
402: