Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
77.24% |
95 / 123 |
|
94.12% |
16 / 17 |
CRAP | |
0.00% |
0 / 1 |
| Database | |
77.24% |
95 / 123 |
|
94.12% |
16 / 17 |
44.08 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| setDb | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getDb | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTable | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolvePlaceholders | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| findRow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| getItemTtl | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| saveItem | |
46.15% |
24 / 52 |
|
0.00% |
0 / 1 |
14.65 | |||
| getItem | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| hasItem | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| deleteItem | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| clear | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| destroy | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| incrementItem | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| decrementItem | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| setTable | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| createTable | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | /** |
| 4 | * Pop PHP Framework (https://www.popphp.org/) |
| 5 | * |
| 6 | * @link https://github.com/popphp/popphp-framework |
| 7 | * @author Nick Sagona, III <nick@popphp.org> |
| 8 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 9 | * @license https://www.popphp.org/license New BSD License |
| 10 | */ |
| 11 | |
| 12 | /** |
| 13 | * @namespace |
| 14 | */ |
| 15 | namespace Pop\Cache\Adapter; |
| 16 | |
| 17 | use Pop\Db; |
| 18 | use Pop\Cache\Clock; |
| 19 | |
| 20 | /** |
| 21 | * Database cache adapter class |
| 22 | * |
| 23 | * @category Pop |
| 24 | * @package Pop\Cache |
| 25 | * @author Nick Sagona, III <nick@popphp.org> |
| 26 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 27 | * @license https://www.popphp.org/license New BSD License |
| 28 | * @version 5.0.0 |
| 29 | */ |
| 30 | class Database extends AbstractAdapter |
| 31 | { |
| 32 | |
| 33 | /** |
| 34 | * Database adapter |
| 35 | * @var ?Db\Adapter\AbstractAdapter |
| 36 | */ |
| 37 | protected ?Db\Adapter\AbstractAdapter $db = null; |
| 38 | |
| 39 | /** |
| 40 | * Cache db table |
| 41 | * @var string |
| 42 | */ |
| 43 | protected string $table = 'pop_cache'; |
| 44 | |
| 45 | /** |
| 46 | * Constructor |
| 47 | * |
| 48 | * Instantiate the DB writer object |
| 49 | * |
| 50 | * The DB table requires the following fields at a minimum: |
| 51 | |
| 52 | * id INT |
| 53 | * key VARCHAR |
| 54 | * start INT |
| 55 | * ttl INT |
| 56 | * value TEXT, VARCHAR, etc. |
| 57 | * |
| 58 | * @param Db\Adapter\AbstractAdapter $db |
| 59 | * @param int $ttl |
| 60 | * @param string $table |
| 61 | * @param Clock\ClockInterface $clock |
| 62 | */ |
| 63 | public function __construct( |
| 64 | Db\Adapter\AbstractAdapter $db, int $ttl = 0, string $table = 'pop_cache', |
| 65 | Clock\ClockInterface $clock = new Clock\SystemClock() |
| 66 | ) |
| 67 | { |
| 68 | parent::__construct($ttl, $clock); |
| 69 | |
| 70 | $this->setDb($db); |
| 71 | $this->setTable($table); |
| 72 | |
| 73 | if (!$db->hasTable($this->table)) { |
| 74 | $this->createTable(); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Set the current cache db adapter. |
| 80 | * |
| 81 | * @param Db\Adapter\AbstractAdapter $db |
| 82 | * @return Database |
| 83 | */ |
| 84 | public function setDb(Db\Adapter\AbstractAdapter $db): Database |
| 85 | { |
| 86 | $this->db = $db; |
| 87 | return $this; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Get the current cache db adapter. |
| 92 | * |
| 93 | * @return Db\Adapter\AbstractAdapter |
| 94 | */ |
| 95 | public function getDb(): Db\Adapter\AbstractAdapter |
| 96 | { |
| 97 | return $this->db; |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Get the current cache db table. |
| 102 | * |
| 103 | * @return string |
| 104 | */ |
| 105 | public function getTable(): string |
| 106 | { |
| 107 | return $this->table; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Resolve the driver-specific placeholder tokens for an ordered list of parameter names |
| 112 | * |
| 113 | * @param Db\Sql $sql |
| 114 | * @param array $names |
| 115 | * @return array |
| 116 | */ |
| 117 | protected function resolvePlaceholders(Db\Sql $sql, array $names): array |
| 118 | { |
| 119 | $placeholder = $sql->getPlaceholder(); |
| 120 | |
| 121 | if ($placeholder == ':') { |
| 122 | return array_map(fn($name) => ':' . $name, $names); |
| 123 | } else if ($placeholder == '$') { |
| 124 | return array_map(fn($i) => '$' . ($i + 1), array_keys($names)); |
| 125 | } |
| 126 | |
| 127 | return array_fill(0, count($names), '?'); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Fetch the raw stored row for an item id, or null if it doesn't exist |
| 132 | * |
| 133 | * @param string $id |
| 134 | * @return ?array |
| 135 | */ |
| 136 | protected function findRow(string $id): ?array |
| 137 | { |
| 138 | $sql = $this->db->createSql(); |
| 139 | [$placeholder] = $this->resolvePlaceholders($sql, ['key']); |
| 140 | |
| 141 | $sql->select()->from($this->table)->where('key = ' . $placeholder); |
| 142 | |
| 143 | $this->db->prepare($sql) |
| 144 | ->bindParams(['key' => sha1($id)]) |
| 145 | ->execute(); |
| 146 | |
| 147 | $rows = $this->db->fetchAll(); |
| 148 | |
| 149 | return $rows[0] ?? null; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Get the time-to-live for an item in cache |
| 154 | * |
| 155 | * @param string $id |
| 156 | * @param int $default |
| 157 | * @return int |
| 158 | */ |
| 159 | public function getItemTtl(string $id, int $default = 0): int |
| 160 | { |
| 161 | $row = $this->findRow($id); |
| 162 | |
| 163 | return (isset($row['ttl'])) ? (int)$row['ttl'] : $default; |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Save an item to cache |
| 168 | * |
| 169 | * @param string $id |
| 170 | * @param mixed $value |
| 171 | * @param ?int $ttl |
| 172 | * @return Database |
| 173 | */ |
| 174 | public function saveItem(string $id, mixed $value, ?int $ttl = null): Database |
| 175 | { |
| 176 | $sql = $this->db->createSql(); |
| 177 | $dbType = $sql->getDbType(); |
| 178 | $key = sha1($id); |
| 179 | $params = [ |
| 180 | 'key' => $key, |
| 181 | 'start' => $this->clock->now(), |
| 182 | 'ttl' => ($ttl !== null) ? $ttl : $this->ttl, |
| 183 | 'value' => serialize($value) |
| 184 | ]; |
| 185 | |
| 186 | if (($dbType == Db\Sql::MYSQL) || ($dbType == Db\Sql::SQLITE) || ($dbType == Db\Sql::PGSQL)) { |
| 187 | [$keyPh, $startPh, $ttlPh, $valuePh] = $this->resolvePlaceholders($sql, ['key', 'start', 'ttl', 'value']); |
| 188 | |
| 189 | $insert = $sql->insert($this->table)->values([ |
| 190 | 'key' => $keyPh, |
| 191 | 'start' => $startPh, |
| 192 | 'ttl' => $ttlPh, |
| 193 | 'value' => $valuePh |
| 194 | ]); |
| 195 | |
| 196 | if ($dbType == Db\Sql::MYSQL) { |
| 197 | $insert->onDuplicateKeyUpdate(['start', 'ttl', 'value']); |
| 198 | } else { |
| 199 | $insert->onConflict(['start', 'ttl', 'value'], 'key'); |
| 200 | } |
| 201 | |
| 202 | $this->db->prepare($sql) |
| 203 | ->bindParams($params) |
| 204 | ->execute(); |
| 205 | } else { |
| 206 | // SQL Server: pop-db has no MERGE/upsert support for this driver, so fall back to |
| 207 | // select-then-insert-or-update, narrowing (not eliminating) the race with a transaction. |
| 208 | $this->db->transaction(function() use ($sql, $key, $params) { |
| 209 | [$lookupPlaceholder] = $this->resolvePlaceholders($sql, ['key']); |
| 210 | |
| 211 | $sql->select()->from($this->table)->where('key = ' . $lookupPlaceholder); |
| 212 | $this->db->prepare($sql)->bindParams(['key' => $key])->execute(); |
| 213 | $rows = $this->db->fetchAll(); |
| 214 | |
| 215 | $sql->reset(); |
| 216 | |
| 217 | if (count($rows) == 0) { |
| 218 | [$keyPh, $startPh, $ttlPh, $valuePh] = $this->resolvePlaceholders($sql, ['key', 'start', 'ttl', 'value']); |
| 219 | $sql->insert($this->table)->values([ |
| 220 | 'key' => $keyPh, |
| 221 | 'start' => $startPh, |
| 222 | 'ttl' => $ttlPh, |
| 223 | 'value' => $valuePh |
| 224 | ]); |
| 225 | $this->db->prepare($sql)->bindParams($params)->execute(); |
| 226 | } else { |
| 227 | [$startPh, $ttlPh, $valuePh, $keyPh] = $this->resolvePlaceholders($sql, ['start', 'ttl', 'value', 'key']); |
| 228 | $sql->update($this->table)->values([ |
| 229 | 'start' => $startPh, |
| 230 | 'ttl' => $ttlPh, |
| 231 | 'value' => $valuePh |
| 232 | ])->where('key = ' . $keyPh); |
| 233 | $this->db->prepare($sql)->bindParams([ |
| 234 | 'start' => $params['start'], |
| 235 | 'ttl' => $params['ttl'], |
| 236 | 'value' => $params['value'], |
| 237 | 'key' => $key |
| 238 | ])->execute(); |
| 239 | } |
| 240 | }); |
| 241 | } |
| 242 | |
| 243 | return $this; |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Get an item from cache |
| 248 | * |
| 249 | * @param string $id |
| 250 | * @param mixed $default |
| 251 | * @return mixed |
| 252 | */ |
| 253 | public function getItem(string $id, mixed $default = false): mixed |
| 254 | { |
| 255 | $value = $default; |
| 256 | $row = $this->findRow($id); |
| 257 | |
| 258 | // If the value is found, check expiration and return. |
| 259 | if ($row !== null) { |
| 260 | if ($this->isFresh($row)) { |
| 261 | $value = unserialize($row['value'], ['allowed_classes' => false]); |
| 262 | } else { |
| 263 | $this->deleteItem($id); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | return $value; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Determine if the item exist in cache |
| 272 | * |
| 273 | * @param string $id |
| 274 | * @return bool |
| 275 | */ |
| 276 | public function hasItem(string $id): bool |
| 277 | { |
| 278 | $row = $this->findRow($id); |
| 279 | |
| 280 | return ($row !== null) && $this->isFresh($row); |
| 281 | } |
| 282 | |
| 283 | /** |
| 284 | * Delete a value in cache |
| 285 | * |
| 286 | * @param string $id |
| 287 | * @return Database |
| 288 | */ |
| 289 | public function deleteItem(string $id): Database |
| 290 | { |
| 291 | $sql = $this->db->createSql(); |
| 292 | [$placeholder] = $this->resolvePlaceholders($sql, ['key']); |
| 293 | |
| 294 | $sql->delete($this->table)->where('key = ' . $placeholder); |
| 295 | |
| 296 | $this->db->prepare($sql) |
| 297 | ->bindParams(['key' => sha1($id)]) |
| 298 | ->execute(); |
| 299 | |
| 300 | return $this; |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Clear all stored values from cache |
| 305 | * |
| 306 | * @return Database |
| 307 | */ |
| 308 | public function clear(): Database |
| 309 | { |
| 310 | $sql = $this->db->createSql(); |
| 311 | $sql->delete($this->table); |
| 312 | $this->db->query($sql); |
| 313 | |
| 314 | return $this; |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Destroy cache resource |
| 319 | * |
| 320 | * @return Database |
| 321 | */ |
| 322 | public function destroy(): Database |
| 323 | { |
| 324 | $this->clear(); |
| 325 | return $this; |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Atomically increment a counter in cache, creating it at $initial if it doesn't exist |
| 330 | * |
| 331 | * Non-atomic read-modify-write through the same start/ttl/value envelope used by saveItem()/getItem() — |
| 332 | * Database has no native atomic primitive, so a counter here is an ordinary cached integer, fully |
| 333 | * interoperable with getItem()/hasItem()/deleteItem(). |
| 334 | * |
| 335 | * @param string $id |
| 336 | * @param int $amount |
| 337 | * @param int $initial |
| 338 | * @param ?int $ttl |
| 339 | * @throws Exception |
| 340 | * @return int |
| 341 | */ |
| 342 | public function incrementItem(string $id, int $amount = 1, int $initial = 0, ?int $ttl = null): int |
| 343 | { |
| 344 | $current = $this->getItem($id, $initial); |
| 345 | |
| 346 | if (!is_int($current)) { |
| 347 | throw new Exception('Error: The value at that key is not numeric.'); |
| 348 | } |
| 349 | |
| 350 | $value = $current + $amount; |
| 351 | $this->saveItem($id, $value, $ttl); |
| 352 | |
| 353 | return $value; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Atomically decrement a counter in cache, creating it at $initial if it doesn't exist |
| 358 | * |
| 359 | * Non-atomic read-modify-write through the same start/ttl/value envelope used by saveItem()/getItem() — |
| 360 | * Database has no native atomic primitive, so a counter here is an ordinary cached integer, fully |
| 361 | * interoperable with getItem()/hasItem()/deleteItem(). |
| 362 | * |
| 363 | * @param string $id |
| 364 | * @param int $amount |
| 365 | * @param int $initial |
| 366 | * @param ?int $ttl |
| 367 | * @throws Exception |
| 368 | * @return int |
| 369 | */ |
| 370 | public function decrementItem(string $id, int $amount = 1, int $initial = 0, ?int $ttl = null): int |
| 371 | { |
| 372 | $current = $this->getItem($id, $initial); |
| 373 | |
| 374 | if (!is_int($current)) { |
| 375 | throw new Exception('Error: The value at that key is not numeric.'); |
| 376 | } |
| 377 | |
| 378 | $value = $current - $amount; |
| 379 | $this->saveItem($id, $value, $ttl); |
| 380 | |
| 381 | return $value; |
| 382 | } |
| 383 | |
| 384 | /** |
| 385 | * Set the cache db table |
| 386 | * |
| 387 | * @param string $table |
| 388 | * @return Database |
| 389 | */ |
| 390 | public function setTable(string $table): Database |
| 391 | { |
| 392 | $this->table = $table; |
| 393 | return $this; |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Create table in database |
| 398 | * |
| 399 | * @return void |
| 400 | */ |
| 401 | protected function createTable(): void |
| 402 | { |
| 403 | $schema = $this->db->createSchema(); |
| 404 | $schema->create($this->table) |
| 405 | ->int('id')->increment() |
| 406 | ->varchar('key', 255) |
| 407 | ->int('start') |
| 408 | ->int('ttl') |
| 409 | ->text('value') |
| 410 | ->primary('id') |
| 411 | ->unique('key', $this->table . '_key_unique'); |
| 412 | |
| 413 | $schema->execute(); |
| 414 | } |
| 415 | } |