Passed
Push — master ( 39a448...3b1ecc )
by Jonathan
09:21
created

SQLCache::increment()   B

Complexity

Conditions 7
Paths 14

Size

Total Lines 30
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 9.4499

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 19
c 1
b 0
f 0
dl 0
loc 30
ccs 12
cts 19
cp 0.6316
rs 8.8333
cc 7
nc 14
nop 3
crap 9.4499
1
<?php
2
/** @noinspection SqlResolve */
3
/** @noinspection SqlWithoutWhere */
4
/** @noinspection SqlNoDataSourceInspection */
5
/** @noinspection PhpComposerExtensionStubsInspection */
6
7
namespace Vectorface\Cache;
8
9
use PDO;
10
use PDOStatement;
11
use PDOException;
12
use Vectorface\Cache\Common\PSR16Util;
13
14
/**
15
 * This cache is slow, according to basic benchmarks:
16
 *
17
 * Parameters:
18
 *   MySQL 5.0, running locally
19
 *   9-byte key
20
 *   151-byte value
21
 *   10000-iteration test
22
 *
23
 * Result:
24
 *   16.7824881077 seconds
25
 *
26
 * Conclusion:
27
 *   Capable of approximately 595.85 requests/second
28
 */
29
30
/**
31
 * A cache implementation that uses SQL for storage.
32
 *
33
 * An example table might look like:
34
 * CREATE TABLE cache (
35
 *     entry VARCHAR(64) PRIMARY KEY NOT NULL,
36
 *     value LONGBLOB,
37
 *     expires BIGINT UNSIGNED DEFAULT NULL,
38
 *     KEY expires (expires)
39
 * ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
40
 */
41
class SQLCache implements Cache, AtomicCounter
42
{
43
    use PSR16Util;
44
45
    /**
46
     * Hash keys beyond this size
47
     */
48
    const MAX_KEY_LEN = 64;
49
50
    /**
51
     * Statement for flushing all entries from the cache.
52
     */
53
    const FLUSH_SQL = 'DELETE FROM cache';
54
55
    /**
56
     * Statement for deleting expired entries from the cache.
57
     */
58
    const CLEAN_SQL = 'DELETE FROM cache WHERE expires<=UNIX_TIMESTAMP()';
59
60
    /**
61
     * Statement for inserting or updating entries in the cache.
62
     */
63
    const SET_SQL = 'INSERT INTO cache (entry,value,expires) VALUES(?,?,?)';
64
65
    /**
66
     * Statement for updating if an entry already exists.
67
     */
68
    const UPDATE_SQL = 'UPDATE cache SET value=?, expires=? WHERE entry=?';
69
70
    /**
71
     * Statement for checking if an entry exists
72
     */
73
    const HAS_SQL = 'SELECT COUNT(*) AS num FROM cache WHERE entry=? AND expires>=UNIX_TIMESTAMP()';
74
75
    /**
76
     * Statement for retrieving an entry from the cache
77
     */
78
    const GET_SQL = 'SELECT value FROM cache WHERE entry=? AND expires>=UNIX_TIMESTAMP()';
79
80
    /**
81
     * Statement for retrieving entries from the cache (no statement caching)
82
     */
83
    const MGET_SQL = 'SELECT entry,value FROM cache WHERE entry IN(%s) AND expires>=UNIX_TIMESTAMP()';
84
85
    /**
86
     * Statement for deleting an entry from the cache
87
     */
88
    const DELETE_SQL = 'DELETE FROM cache WHERE entry=?';
89
90
    /**
91
     * Statement for deleting entries from the cache (no statement caching)
92
     */
93
    const MDELETE_SQL = 'DELETE FROM cache WHERE entry IN(%s)';
94
95
    /**
96
     * The database connection to be used for cache operations.
97
     *
98
     * @var PDO
99
     */
100
    private $conn;
101
102
    /**
103
     * An associative array of PDO statements used in get/set.
104
     *
105
     * @var PDOStatement
106
     */
107
    private $statements = [];
108
109
    /**
110
     * Create an instance of the SQL cache.
111
     *
112
     * @param PDO $conn The database connection to use for cache operations.
113
     */
114 21
    public function __construct(PDO $conn)
115
    {
116 21
        $this->conn = $conn;
117 21
    }
118
119
    /**
120
     * @inheritDoc
121
     */
122 17
    public function get($key, $default = null)
123
    {
124 17
        $key = $this->key($key);
125 16
        $key = $this->hashKey($key);
126
127
        try {
128 16
            $stmt = $this->getStatement(__METHOD__, self::GET_SQL);
129 16
            $stmt->execute([$key]);
130 1
        } catch (PDOException $e) {
131 1
            return $default;
132
        }
133 16
        $result = $stmt->fetchColumn();
134 16
        return empty($result) ? $default : unserialize($result);
135
    }
136
137
    /**
138
     * @inheritDoc
139
     */
140 4
    public function getMultiple($keys, $default = null)
141
    {
142 4
        if (empty($keys)) {
143 1
            return [];
144
        }
145
146 4
        $keys = $this->keys($keys);
147 3
        $sqlKeys = array_map([$this, 'hashKey'], $keys);
148
149
        try {
150 3
            $stmt = $this->conn->prepare(sprintf(
151 3
                self::MGET_SQL,
152 3
                implode(',', array_fill(0, count($keys), '?'))
153
            ));
154 2
            $stmt->execute($sqlKeys);
155 2
            $result = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
156 1
        } catch (PDOException $e) {
157 1
            $result = [];
158
        }
159
160 3
        $return = array_map('unserialize', $result);
161 3
        foreach ($keys as $key) {
162 3
            if (!isset($return[$key])) {
163 3
                $return[$key] = $default;
164
            }
165
        }
166 3
        return $return;
167
    }
168
169
170
    /**
171
     * @inheritDoc
172
     */
173 20
    public function set($key, $value, $ttl = null)
174
    {
175 20
        $key = $this->key($key);
176 20
        $key = $this->hashKey($key);
177 20
        $ttl = $this->ttl($ttl);
178 19
        $ttl = $ttl ? ($ttl + time()) : PHP_INT_MAX;
179 19
        $value = serialize($value);
180
181
        try {
182 19
            $stmt = $this->getStatement(__METHOD__ . ".insert", self::SET_SQL);
183 19
            return $stmt->execute([$key, $value, $ttl]);
184 5
        } catch (PDOException $e) {
185
            // Insert can fail if the entry exists; It's normal.
186
        }
187
188
        try {
189 5
            $stmt = $this->getStatement(__METHOD__ . ".update", self::UPDATE_SQL);
190 5
            $success = $stmt->execute([$value, $ttl, $key]);
191 5
            return $success && $stmt->rowCount() === 1;
192 1
        } catch (PDOException $e) {
193 1
            return false;
194
        }
195
    }
196
197
    /**
198
     * @inheritDoc
199
     */
200 3
    public function setMultiple($values, $ttl = null)
201
    {
202 3
        $success = true;
203 3
        foreach ($this->values($values) as $key => $value) {
204 2
            $success = $this->set($key, $value, $ttl) && $success;
205
        }
206 2
        return $success;
207
    }
208
209
    /**
210
     * @inheritDoc
211
     */
212 5
    public function delete($key)
213
    {
214 5
        $key = $this->hashKey($this->key($key));
215
216
        try {
217 5
            $stmt = $this->getStatement(__METHOD__, self::DELETE_SQL);
218 5
            return $stmt->execute([$key]);
219 1
        } catch (PDOException $e) {
220 1
            return false;
221
        }
222
    }
223
224
    /**
225
     * @inheritDoc
226
     */
227 3
    public function deleteMultiple($keys)
228
    {
229 3
        if (empty($keys)) {
230 1
            return true;
231
        }
232
233 3
        $keysArray = array_map([$this, 'hashKey'], is_array($keys) ? $keys : iterator_to_array($keys));
234
235
        try {
236 3
            $stmt = $this->conn->prepare(sprintf(
237 3
                self::MDELETE_SQL,
238 3
                implode(',', array_fill(0, count($keysArray), '?'))
239
            ));
240 2
            $stmt->execute($keysArray);
241 1
        } catch (PDOException $e) {
242 1
            return false;
243
        }
244 2
        return true;
245
    }
246
247
    /**
248
     * @inheritdoc
249
     */
250 2
    public function clean()
251
    {
252
        try {
253 2
            $this->conn->exec(self::CLEAN_SQL);
254 1
        } catch (PDOException $e) {
255 1
            return false;
256
        }
257 2
        return true;
258
    }
259
260
    /**
261
     * @inheritdoc
262
     */
263 7
    public function flush()
264
    {
265
        try {
266 7
            $this->conn->exec(self::FLUSH_SQL);
267 1
        } catch (PDOException $e) {
268 1
            return false;
269
        }
270 7
        return true;
271
    }
272
273
    /**
274
     * @inheritDoc
275
     */
276 3
    public function clear()
277
    {
278 3
        return $this->flush();
279
    }
280
281
    /**
282
     * @inheritDoc
283
     */
284 2
    public function has($key)
285
    {
286 2
        $key = $this->hashKey($key);
287
288
        try {
289 2
            $stmt = $this->getStatement(__METHOD__, self::HAS_SQL);
290 1
            $stmt->execute([$key]);
291 1
        } catch (PDOException $e) {
292 1
            return false;
293
        }
294 1
        return $stmt->fetchColumn() ? true : false;
295
    }
296
297
    /**
298
     * @inheritDoc
299
     * @throws Exception\CacheException
300
     */
301 1
    public function increment($key, $step = 1, $ttl = null)
302
    {
303 1
        $step = $this->step($step);
304
305
        try {
306 1
            $result = $this->conn->beginTransaction();
307 1
            if (!$result) {
308
                return false;
309
            }
310
311 1
            $current = $this->get($key);
312 1
            $next = ($current ?? 0) + $step;
313 1
            $result = $this->set($key, $next, ($current === null ? $ttl : null));
314 1
            if (!$result) {
315
                $this->conn->rollBack();
316
                return false;
317
            }
318
319 1
            $result = $this->conn->commit();
320 1
            if (!$result) {
321 1
                return false;
322
            }
323
        } catch (PDOException $e) {
324
            if ($this->conn->inTransaction()) {
325
                $this->conn->rollBack();
326
            }
327
            return false;
328
        }
329
330 1
        return $next;
331
    }
332
333
    /**
334
     * @inheritDoc
335
     * @throws Exception\CacheException
336
     */
337 1
    public function decrement($key, $step = 1, $ttl = null)
338
    {
339 1
        $step = $this->step($step);
340
341
        try {
342 1
            $result = $this->conn->beginTransaction();
343 1
            if (!$result) {
344
                return false;
345
            }
346
347 1
            $current = $this->get($key);
348 1
            $next = ($current ?? 0) - $step;
349 1
            $result = $this->set($key, $next, ($current === null ? $ttl : null));
350 1
            if (!$result) {
351
                $this->conn->rollBack();
352
                return false;
353
            }
354
355 1
            $result = $this->conn->commit();
356 1
            if (!$result) {
357 1
                return false;
358
            }
359
        } catch (PDOException $e) {
360
            if ($this->conn->inTransaction()) {
361
                $this->conn->rollBack();
362
            }
363
            return false;
364
        }
365
366 1
        return $next;
367
    }
368
369
    /**
370
     * Get a prepared statement for the given method's SQL.
371
     *
372
     * The result is stored internally to limit repeated preparing of SQL.
373
     *
374
     * @param string $method The method name to for which this statement applies.
375
     * @param string $sql The SQL statement associated with the given method.
376
     * @return PDOStatement Returns the prepared statement for the given method.
377
     */
378 19
    private function getStatement($method, $sql)
379
    {
380 19
        if (empty($this->statements[$method])) {
381 19
            $this->statements[$method] = $this->conn->prepare($sql);
382
        }
383 19
        return $this->statements[$method];
384
    }
385
386
    /**
387
     * Get a unique hash key when the key is too long
388
     *
389
     * @param string $key
390
     * @return string The key, or the hash of the key parameter if it goes beyond maximum length
391
     * @private Public for testing
392
     */
393 20
    public static function hashKey($key)
394
    {
395 20
        return (strlen($key) > self::MAX_KEY_LEN) ? hash('sha256', $key) : $key;
396
    }
397
}
398