Completed
Push — master ( 8808e8...e1546f )
by Matthias
02:43
created

Memcached::decrement()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 12
Code Lines 4

Duplication

Lines 12
Ratio 100 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 12
loc 12
rs 9.4285
cc 3
eloc 4
nc 2
nop 4
1
<?php
2
3
namespace MatthiasMullie\Scrapbook\Adapters;
4
5
use MatthiasMullie\Scrapbook\Adapters\Collections\Memcached as Collection;
6
use MatthiasMullie\Scrapbook\Exception\InvalidKey;
7
use MatthiasMullie\Scrapbook\Exception\OperationFailed;
8
use MatthiasMullie\Scrapbook\KeyValueStore;
9
10
/**
11
 * Memcached adapter. Basically just a wrapper over \Memcached, but in an
12
 * exchangeable (KeyValueStore) interface.
13
 *
14
 * @author Matthias Mullie <[email protected]>
15
 * @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved
16
 * @license LICENSE MIT
17
 */
18
class Memcached implements KeyValueStore
19
{
20
    /**
21
     * @var \Memcached
22
     */
23
    protected $client;
24
25
    /**
26
     * @param \Memcached $client
27
     */
28
    public function __construct(\Memcached $client)
29
    {
30
        $this->client = $client;
31
    }
32
33
    /**
34
     * {@inheritdoc}
35
     */
36
    public function get($key, &$token = null)
37
    {
38
        /**
39
         * Wouldn't it be awesome if I just used the obvious method?
40
         *
41
         * I'm going to use getMulti() instead of get() because the latter is
42
         * flawed in earlier versions, where it was known to mess up some
43
         * operations that are followed by it (increment/decrement have been
44
         * reported, also seen it make CAS return result unreliable)
45
         *
46
         * @see https://github.com/php-memcached-dev/php-memcached/issues/21
47
         */
48
        $values = $this->getMulti(array($key), $tokens);
49
50
        if (!isset($values[$key])) {
51
            $token = null;
52
53
            return false;
54
        }
55
56
        $token = $tokens[$key];
57
58
        return $values[$key];
59
    }
60
61
    /**
62
     * {@inheritdoc}
63
     */
64
    public function getMulti(array $keys, array &$tokens = null)
65
    {
66
        $tokens = array();
67
        if (empty($keys)) {
68
            return array();
69
        }
70
71
        $keys = array_map(array($this, 'encode'), $keys);
72
73
        if (defined('\Memcached::GET_EXTENDED')) {
74
            $return = $this->client->getMulti($keys, \Memcached::GET_EXTENDED);
75
            $this->throwExceptionOnClientCallFailure($return);
76
            foreach ($return as $key => $value) {
77
                // once PHP<5.5 support is dropped, just use array_column
78
                $tokens[$key] = $value['cas'];
79
                $return[$key] = $value['value'];
80
            }
81
        } else {
82
            $return = $this->client->getMulti($keys, $tokens);
83
            $this->throwExceptionOnClientCallFailure($return);
84
        }
85
86
        $keys = array_map(array($this, 'decode'), array_keys($return));
87
        $return = array_combine($keys, $return);
88
89
        // HHVMs getMulti() returns null instead of empty array for no results,
90
        // so normalize that
91
        $tokens = $tokens ?: array();
92
        $tokens = array_combine($keys, $tokens);
93
94
        return $return ?: array();
95
    }
96
97
    /**
98
     * {@inheritdoc}
99
     */
100
    public function set($key, $value, $expire = 0)
101
    {
102
        // Memcached seems to not timely purge items the way it should when
103
        // storing it with an expired timestamp
104 View Code Duplication
        if ($expire < 0 || ($expire > 2592000 && $expire < time())) {
0 ignored issues
show
Duplication introduced by Matthias Mullie
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
105
            $this->delete($key);
106
107
            return true;
108
        }
109
110
        $key = $this->encode($key);
111
112
        return $this->client->set($key, $value, $expire);
113
    }
114
115
    /**
116
     * {@inheritdoc}
117
     */
118
    public function setMulti(array $items, $expire = 0)
119
    {
120
        if (empty($items)) {
121
            return array();
122
        }
123
124
        // Memcached seems to not timely purge items the way it should when
125
        // storing it with an expired timestamp
126 View Code Duplication
        if ($expire < 0 || ($expire > 2592000 && $expire < time())) {
0 ignored issues
show
Duplication introduced by Matthias Mullie
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
127
            $keys = array_keys($items);
128
            $this->deleteMulti($keys);
129
130
            return array_fill_keys($keys, true);
131
        }
132
133
        if (defined('HHVM_VERSION')) {
134
            $nums = array_filter(array_keys($items), 'is_numeric');
135
            if (!empty($nums)) {
136
                return $this->setMultiNumericItemsForHHVM($items, $nums, $expire);
137
            }
138
        }
139
140
        $keys = array_map(array($this, 'encode'), array_keys($items));
141
        $items = array_combine($keys, $items);
142
        $success = $this->client->setMulti($items, $expire);
143
        $keys = array_map(array($this, 'decode'), array_keys($items));
144
145
        return array_fill_keys($keys, $success);
146
    }
147
148
    /**
149
     * {@inheritdoc}
150
     */
151
    public function delete($key)
152
    {
153
        $key = $this->encode($key);
154
155
        return $this->client->delete($key);
156
    }
157
158
    /**
159
     * {@inheritdoc}
160
     */
161
    public function deleteMulti(array $keys)
162
    {
163
        if (empty($keys)) {
164
            return array();
165
        }
166
167
        if (!method_exists($this->client, 'deleteMulti')) {
168
            /**
169
             * HHVM didn't always support deleteMulti, so I'll hack around it by
170
             * setting all items expired.
171
             * I could also delete() all items one by one, but that would
172
             * probably take more network requests (this version always takes 2).
173
             *
174
             * @see http://docs.hhvm.com/manual/en/memcached.deletemulti.php
175
             */
176
            $values = $this->getMulti($keys);
177
178
            $keys = array_map(array($this, 'encode'), array_keys($values));
179
            $this->client->setMulti(array_fill_keys($keys, ''), time() - 1);
180
181
            $return = array();
182
            foreach ($keys as $key) {
183
                $key = $this->decode($key);
184
                $return[$key] = array_key_exists($key, $values);
185
            }
186
187
            return $return;
188
        }
189
190
        $keys = array_map(array($this, 'encode'), $keys);
191
        $result = (array) $this->client->deleteMulti($keys);
192
        $keys = array_map(array($this, 'decode'), array_keys($result));
193
        $result = array_combine($keys, $result);
194
195
        /*
196
         * Contrary to docs (http://php.net/manual/en/memcached.deletemulti.php)
197
         * deleteMulti returns an array of [key => true] (for successfully
198
         * deleted values) and [key => error code] (for failures)
199
         * Pretty good because I want an array of true/false, so I'll just have
200
         * to replace the error codes by falses.
201
         */
202
        foreach ($result as $key => $status) {
203
            $result[$key] = $status === true;
204
        }
205
206
        return $result;
207
    }
208
209
    /**
210
     * {@inheritdoc}
211
     */
212
    public function add($key, $value, $expire = 0)
213
    {
214
        $key = $this->encode($key);
215
216
        return $this->client->add($key, $value, $expire);
217
    }
218
219
    /**
220
     * {@inheritdoc}
221
     */
222
    public function replace($key, $value, $expire = 0)
223
    {
224
        $key = $this->encode($key);
225
226
        return $this->client->replace($key, $value, $expire);
227
    }
228
229
    /**
230
     * {@inheritdoc}
231
     */
232 View Code Duplication
    public function cas($token, $key, $value, $expire = 0)
0 ignored issues
show
Duplication introduced by Matthias Mullie
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
233
    {
234
        if (!is_float($token)) {
235
            return false;
236
        }
237
238
        $key = $this->encode($key);
239
240
        return $this->client->cas($token, $key, $value, $expire);
241
    }
242
243
    /**
244
     * {@inheritdoc}
245
     */
246 View Code Duplication
    public function increment($key, $offset = 1, $initial = 0, $expire = 0)
0 ignored issues
show
Duplication introduced by Matthias Mullie
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
247
    {
248
        if ($offset <= 0 || $initial < 0) {
249
            return false;
250
        }
251
252
        /*
253
         * Not doing \Memcached::increment because that one:
254
         * * needs \Memcached::OPT_BINARY_PROTOCOL == true
255
         * * is prone to errors after a flush ("merges" with pruned data) in at
256
         *   least some particular versions of Memcached
257
         */
258
        return $this->doIncrement($key, $offset, $initial, $expire);
0 ignored issues
show
Bug Compatibility introduced by Matthias Mullie
The expression $this->doIncrement($key,...et, $initial, $expire); of type integer|false|double adds the type double to the return on line 258 which is incompatible with the return type declared by the interface MatthiasMullie\Scrapbook\KeyValueStore::increment of type integer|boolean.
Loading history...
259
    }
260
261
    /**
262
     * {@inheritdoc}
263
     */
264 View Code Duplication
    public function decrement($key, $offset = 1, $initial = 0, $expire = 0)
0 ignored issues
show
Duplication introduced by Matthias Mullie
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
265
    {
266
        if ($offset <= 0 || $initial < 0) {
267
            return false;
268
        }
269
270
        /*
271
         * Not doing \Memcached::decrement for the reasons described in:
272
         * @see increment()
273
         */
274
        return $this->doIncrement($key, -$offset, $initial, $expire);
0 ignored issues
show
Bug Compatibility introduced by Matthias Mullie
The expression $this->doIncrement($key,...et, $initial, $expire); of type integer|false|double adds the type double to the return on line 274 which is incompatible with the return type declared by the interface MatthiasMullie\Scrapbook\KeyValueStore::decrement of type integer|boolean.
Loading history...
275
    }
276
277
    /**
278
     * {@inheritdoc}
279
     */
280
    public function touch($key, $expire)
281
    {
282
        /*
283
         * Since \Memcached has no reliable touch(), we might as well take an
284
         * easy approach where we can. If TTL is expired already, just delete
285
         * the key - this only needs 1 request.
286
         */
287 View Code Duplication
        if ($expire < 0 || ($expire > 2592000 && $expire < time())) {
0 ignored issues
show
Duplication introduced by Matthias Mullie
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
288
            return $this->delete($key);
289
        }
290
291
        /**
292
         * HHVM doesn't support touch.
293
         *
294
         * @see http://docs.hhvm.com/manual/en/memcached.touch.php
295
         *
296
         * PHP does, but only with \Memcached::OPT_BINARY_PROTOCOL == true,
297
         * and even then, it appears to be buggy on particular versions of
298
         * Memcached.
299
         *
300
         * I'll just work around it!
301
         */
302
        $value = $this->get($key, $token);
303
304
        return $this->cas($token, $key, $value, $expire);
305
    }
306
307
    /**
308
     * {@inheritdoc}
309
     */
310
    public function flush()
311
    {
312
        return $this->client->flush();
313
    }
314
315
    /**
316
     * {@inheritdoc}
317
     */
318
    public function getCollection($name)
319
    {
320
        return new Collection($this, $name);
321
    }
322
323
    /**
324
     * Shared between increment/decrement: both have mostly the same logic
325
     * (decrement just increments a negative value), but need their validation
326
     * split up (increment won't accept negative values).
327
     *
328
     * @param string $key
329
     * @param int    $offset
330
     * @param int    $initial
331
     * @param int    $expire
332
     *
333
     * @return int|bool
334
     */
335 View Code Duplication
    protected function doIncrement($key, $offset, $initial, $expire)
0 ignored issues
show
Duplication introduced by Matthias Mullie
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
336
    {
337
        $value = $this->get($key, $token);
338
        if ($value === false) {
339
            $success = $this->add($key, $initial, $expire);
340
341
            return $success ? $initial : false;
342
        }
343
344
        if (!is_numeric($value) || $value < 0) {
345
            return false;
346
        }
347
348
        $value += $offset;
349
        // value can never be lower than 0
350
        $value = max(0, $value);
351
        $key = $this->encode($key);
352
        $success = $this->client->cas($token, $key, $value, $expire);
353
354
        return $success ? $value : false;
355
    }
356
357
    /**
358
     * Encode a key for use on the wire inside the memcached protocol.
359
     *
360
     * We encode spaces and line breaks to avoid protocol errors. We encode
361
     * the other control characters for compatibility with libmemcached
362
     * verify_key. We leave other punctuation alone, to maximise backwards
363
     * compatibility.
364
     *
365
     * @see https://github.com/wikimedia/mediawiki/commit/be76d869#diff-75b7c03970b5e43de95ff95f5faa6ef1R100
366
     * @see https://github.com/wikimedia/mediawiki/blob/master/includes/libs/objectcache/MemcachedBagOStuff.php#L116
367
     *
368
     * @param string $key
369
     *
370
     * @return string
371
     *
372
     * @throws InvalidKey
373
     */
374
    protected function encode($key)
375
    {
376
        $regex = '/[^\x21\x22\x24\x26-\x39\x3b-\x7e]+/';
377
        $key = preg_replace_callback($regex, function ($match) {
378
            return rawurlencode($match[0]);
379
        }, $key);
380
381
        if (strlen($key) > 255) {
382
            throw new InvalidKey(
383
                "Invalid key: $key. Encoded Memcached keys can not exceed 255 chars."
384
            );
385
        }
386
387
        return $key;
388
    }
389
390
    /**
391
     * Decode a key encoded with encode().
392
     *
393
     * @param string $key
394
     *
395
     * @return string
396
     */
397
    protected function decode($key)
398
    {
399
        // matches %20, %7F, ... but not %21, %22, ...
0 ignored issues
show
Unused Code Comprehensibility introduced by Matthias Mullie
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
400
        // (=the encoded versions for those encoded in encode)
401
        $regex = '/%(?!2[1246789]|3[0-9]|3[B-F]|[4-6][0-9A-F]|5[0-9A-E])[0-9A-Z]{2}/i';
402
403
        return preg_replace_callback($regex, function ($match) {
404
            return rawurldecode($match[0]);
405
        }, $key);
406
    }
407
408
    /**
409
     * Numerical strings turn into integers when used as array keys, and
410
     * HHVM (used to) reject(s) such cache keys.
411
     *
412
     * @see https://github.com/facebook/hhvm/pull/7654
413
     *
414
     * @param array $items
415
     * @param array $nums
416
     * @param int   $expire
417
     *
418
     * @return array
419
     */
420
    protected function setMultiNumericItemsForHHVM(array $items, array $nums, $expire = 0)
421
    {
422
        $success = [];
423
        $nums = array_intersect_key($items, array_fill_keys($nums, null));
424
        foreach ($nums as $k => $v) {
425
            $success[$k] = $this->set((string) $k, $v, $expire);
426
        }
427
428
        $remaining = array_diff_key($items, $nums);
429
        if ($remaining) {
0 ignored issues
show
Bug Best Practice introduced by Matthias Mullie
The expression $remaining of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
430
            $success += $this->setMulti($remaining, $expire);
431
        }
432
433
        return $success;
434
    }
435
436
    /**
437
     * Will throw an exception if the returned result from a Memcached call
438
     * indicates a failure in the operation.
439
     * The exception will contain debug information about the failure.
440
     *
441
     * @param mixed $result
442
     *
443
     * @throws OperationFailed
444
     */
445
    protected function throwExceptionOnClientCallFailure($result)
446
    {
447
        if ($result !== false) {
448
            return;
449
        }
450
451
        throw new OperationFailed(
452
            $this->client->getResultMessage(),
453
            $this->client->getResultCode()
454
        );
455
    }
456
}
457