Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.24% covered (success)
77.24%
95 / 123
94.12% covered (success)
94.12%
16 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Database
77.24% covered (success)
77.24%
95 / 123
94.12% covered (success)
94.12%
16 / 17
44.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setDb
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolvePlaceholders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 findRow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getItemTtl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 saveItem
46.15% covered (warning)
46.15%
24 / 52
0.00% covered (danger)
0.00%
0 / 1
14.65
 getItem
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 hasItem
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 deleteItem
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 destroy
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 incrementItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 decrementItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createTable
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(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 */
15namespace Pop\Cache\Adapter;
16
17use Pop\Db;
18use 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 */
30class 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}