DatabaseCache::setMultiple()   A
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 9
c 1
b 0
f 0
dl 0
loc 19
rs 9.6111
ccs 9
cts 9
cp 1
cc 5
nc 4
nop 2
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Kodus\Cache;
6
7
use DateInterval;
8
use Kodus\Cache\Database\Adapter;
9
use Kodus\Cache\Database\InvalidArgumentException;
10
use Kodus\Cache\Database\MySQLAdapter;
11
use Kodus\Cache\Database\PostgreSQLAdapter;
12
use PDO;
13
use Psr\SimpleCache\CacheInterface;
14
use RuntimeException;
15
use Traversable;
16
use function array_fill_keys;
17
use function date_create_from_format;
18
use function get_class;
19
use function gettype;
20
use function is_array;
21
use function is_int;
22
use function iterator_to_array;
23
use function unserialize;
24
25
/**
26
 * This class implements a PSR-16 cache backed by an SQL database.
27
 *
28
 * Make sure your schedule an e.g. nightly call to {@see cleanExpired()}.
29
 */
30
class DatabaseCache implements CacheInterface
31
{
32
    /**
33
     * @var string control characters for keys, reserved by PSR-16
34
     */
35
    const PSR16_RESERVED = '/\{|\}|\(|\)|\/|\\\\|\@|\:/u';
36
37
    /**
38
     * @var int
39
     */
40
    private $default_ttl;
41
42
    /**
43
     * @var Adapter
44
     */
45
    private $adapter;
46
47
    /**
48
     * @param PDO    $pdo         PDO database connection
49
     * @param string $table_name
50 388
     * @param int    $default_ttl default time-to-live (in seconds)
51
     */
52 388
    public function __construct(PDO $pdo, string $table_name, int $default_ttl)
53 388
    {
54 388
        $this->adapter = $this->createAdapter($pdo, $table_name);
55
        $this->default_ttl = $default_ttl;
56 144
    }
57
58 144
    public function get(string $key, mixed $default = null): mixed
59
    {
60 72
        $this->validateKey($key);
61
62 72
        $entry = $this->adapter->select($key);
63 20
64
        if ($entry === null) {
65
            return $default; // entry not found
66 62
        }
67 8
68
        if ($this->getTime() >= $entry->expires) {
69
            return $default; // entry expired
70 58
        }
71 2
72
        if ($entry->data === 'b:0;') {
73
            return false; // because we can't otherwise distinguish a FALSE return-value from `unserialize()`
74 56
        }
75
76 56
        $value = @unserialize($entry->data);
77
78
        if ($value === false) {
79
            return $default; // `unserialize()` failed
80 56
        }
81
82
        return $value;
83 118
    }
84
85 118
    public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
86
    {
87 82
        $this->validateKey($key);
88
89 82
        $data = serialize($value);
90
91 62
        $this->adapter->upsert([$key => $data], $this->expirationFromTTL($ttl));
92
93
        return true;
94 40
    }
95
96 40
    public function delete(string $key): bool
97
    {
98 4
        $this->validateKey($key);
99
100 4
        $this->adapter->delete($key);
101
102
        return true;
103 388
    }
104
105 388
    public function clear(): bool
106
    {
107 388
        $this->adapter->truncate();
108
109
        return true;
110 60
    }
111
112 60
    public function getMultiple(iterable $keys, mixed $default = null): iterable
113 4
    {
114 2
        if (! is_array($keys)) {
115
            if ($keys instanceof Traversable) {
116 2
                $keys = iterator_to_array($keys, false);
117
            } else {
118
                throw new InvalidArgumentException("keys must be either of type array or Traversable");
119
            }
120 58
        }
121 58
122
        foreach ($keys as $key) {
123
            $this->validateKey($key);
124 22
        }
125
126 22
        $result = $this->adapter->selectMultiple($keys);
127
128 22
        $values = array_fill_keys($keys, $default);
129 22
130
        foreach ($result as $entry) {
131
            if ($entry->data === 'b:0;') {
132 22
                $value = false; // because we can't otherwise distinguish a FALSE return-value from `unserialize()`
133
            } else {
134 22
                $value = @unserialize($entry->data);
135
136
                if ($value === false) {
137
                    $value = $default;
138
                }
139 22
            }
140
141
            $values[$entry->key] = $value;
142 22
        }
143
144
        return $values;
145 84
    }
146
147 84
    public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool
148 2
    {
149
        if (! is_array($values) && ! $values instanceof Traversable) {
150
            throw new InvalidArgumentException("keys must be either of type array or Traversable");
151 82
        }
152
153 82
        $data = [];
154 82
155 80
        foreach ($values as $key => $value) {
156
            if (! is_int($key)) {
157
                $this->validateKey($key);
158 82
            }
159
160
            $data[$key] = serialize($value);
161 48
        }
162
163 28
        $this->adapter->upsert($data, $this->expirationFromTTL($ttl));
164
165
        return true;
166 42
    }
167
168 42
    public function deleteMultiple(iterable $keys): bool
169 4
    {
170 2
        if (! is_array($keys)) {
171
            if ($keys instanceof Traversable) {
172 2
                $keys = iterator_to_array($keys, false);
173
            } else {
174
                throw new InvalidArgumentException("keys must be either of type array or Traversable");
175
            }
176 40
        }
177 40
178
        foreach ($keys as $key) {
179
            $this->validateKey($key);
180 4
        }
181 4
182
        if (count($keys)) {
183
            $this->adapter->deleteMultiple($keys);
184 4
        }
185
186
        return true;
187 46
    }
188
189 46
    public function has(string $key): bool
190
    {
191
        return $this->get($key, $this) !== $this;
192
    }
193
194
    /**
195
     * Clean up expired cache-files.
196
     *
197
     * This method is outside the scope of the PSR-16 cache concept, and is specific to
198
     * this implementation.
199
     *
200
     * In scenarios with dynamic keys (such as Session IDs) you should call this method
201
     * periodically - for example from a scheduled daily cron-job.
202
     *
203 2
     * @return void
204
     */
205 2
    public function cleanExpired()
206 2
    {
207
        $this->adapter->deleteExpired($this->getTime());
208
    }
209
210
    /**
211 90
     * @return int current timestamp
212
     */
213 90
    protected function getTime()
214
    {
215
        return time();
216 382
    }
217
218 382
    private function validateKey($key): void
219 96
    {
220
        if (! is_string($key)) {
221 96
            $type = is_object($key) ? get_class($key) : gettype($key);
222
223
            throw new InvalidArgumentException("invalid key type: {$type} given");
224 326
        }
225 14
226
        if ($key === "") {
227
            throw new InvalidArgumentException("invalid key: empty string given");
228 318
        }
229 140
230
        if (preg_match(self::PSR16_RESERVED, $key, $match) === 1) {
231 238
            throw new InvalidArgumentException("invalid character in key: {$match[0]}");
232
        }
233 388
    }
234
235 388
    private function createAdapter(PDO $pdo, string $table_name): Adapter
236
    {
237 388
        $driver_name = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
238 388
239 194
        switch ($driver_name) {
240 194
            case "pgsql":
241 194
                return new PostgreSQLAdapter($pdo, $table_name);
242
            case "mysql":
243
                return new MySQLAdapter($pdo, $table_name);
244
        }
245
246
        throw new RuntimeException("Unsupported PDO driver: {$driver_name}");
247 130
    }
248
249
    private function expirationFromTTL($ttl): int
250
    {
251
        /**
252
         * @var int $expires
253 130
         */
254 10
255 126
        if (is_int($ttl)) {
256 4
            return $this->getTime() + $ttl;
257 122
        } elseif ($ttl instanceof DateInterval) {
258 82
            return date_create_from_format("U", (string) $this->getTime())->add($ttl)->getTimestamp();
259
        } elseif ($ttl === null) {
260 40
            return $this->getTime() + $this->default_ttl;
261
        } else {
262 40
            $type = is_object($ttl) ? get_class($ttl) : gettype($ttl);
263
264
            throw new InvalidArgumentException("invalid TTL type: {$type}");
265
        }
266
    }
267
}
268