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: | |
16: | |
17: | |
18: | |
19: | final class CacheStore extends AbstractStore implements CacheInterface |
20: | { |
21: | private ?SQLite3Stmt $Stmt = null; |
22: | private ?int $Now = null; |
23: | |
24: | |
25: | |
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: | |
57: | |
58: | protected function __clone() {} |
59: | |
60: | |
61: | |
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: | |
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: | |
118: | $row = $result->fetchArray(\SQLITE3_NUM); |
119: | $this->closeStmt(); |
120: | |
121: | return (bool) $row[0]; |
122: | } |
123: | |
124: | |
125: | |
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: | |
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: | |
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: | |
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: | |
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: | |
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: | |
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: | |
226: | |
227: | |
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: | |
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: | |
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: | |
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: | |
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: | |
290: | $row = $result->fetchArray(\SQLITE3_NUM); |
291: | $this->closeStmt(); |
292: | |
293: | return $row[0]; |
294: | } |
295: | |
296: | |
297: | |
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: | |
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: | |
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: | |
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: | |