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