Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.18% covered (success)
90.18%
101 / 112
69.23% covered (warning)
69.23%
18 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
common_persistence_KeyValuePersistence
90.18% covered (success)
90.18%
101 / 112
69.23% covered (warning)
69.23%
18 / 26
71.25
0.00% covered (danger)
0.00%
0 / 1
 set
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 exists
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 del
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 incr
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 decr
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 deleteMappedKey
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 purge
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setLargeValue
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
8.35
 isLarge
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 split
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 join
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 createMap
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 transformReferenceToMappedKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMappedKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMappedKeyIndex
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 serializeMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unSerializeMap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSplit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSize
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 hasMaxSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMapIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getStartMapDelimiter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getEndMapDelimiter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getParam
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 supportsFeature
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; under version 2
7 * of the License (non-upgradable).
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program; if not, write to the Free Software
16 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17 *
18 * Copyright (c) 2013-2017 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
19 *
20 * @author Lionel Lecaque  <lionel@taotesting.com>
21 * @author Camille Moyon  <camille@taotesting.com>
22 * @license GPLv2
23 * @package generis
24
25 *
26 */
27class common_persistence_KeyValuePersistence extends common_persistence_Persistence
28{
29    /**
30     * Ability to set the key only if it does not already exist
31     */
32    public const FEATURE_NX = 'nx';
33
34    public const MAX_VALUE_SIZE = 'max_value_size';
35    public const MAP_IDENTIFIER = 'map_identifier';
36
37    public const START_MAP_DELIMITER = 'start_map_delimiter';
38    public const END_MAP_DELIMITER = 'end_map_delimiter';
39    public const MAPPED_KEY_SEPARATOR = '###';
40    public const LEVEL_SEPARATOR = '-';
41
42    public const DEFAULT_MAP_IDENTIFIER = '<<<<mapped>>>>';
43    public const DEFAULT_START_MAP_DELIMITER = '<<<<mappedKey>>>>';
44    public const DEFAULT_END_MAP_DELIMITER = '<<<</mappedKey>>>>';
45
46
47    /**
48     * @var int The maximum size allowed for the value
49     */
50    protected $size = false;
51
52    /**
53     * Set a $key with a $value
54     * If $value is too large, it is split into multiple $mappedKey.
55     * These new keys are serialized and stored into actual $key
56     *
57     * @param string $key
58     * @param string $value
59     * @param string $ttl
60     * @param bool $nx
61     * @return bool
62     * @throws common_Exception If size is misconfigured
63     */
64    public function set($key, $value, $ttl = null, $nx = false)
65    {
66        if ($this->hasMaxSize()) {
67            if ($this->isLarge($value)) {
68                $value = $this->setLargeValue($key, $value, 0, true, true, $ttl, $nx);
69            }
70        }
71        return $this->getDriver()->set($key, $value, $ttl, $nx);
72    }
73
74    /**
75     * Get $key from driver. If $key is split, all mapped values are retrieved and join to restore original value
76     *
77     * @param string $key
78     * @return bool|int|null|string
79     */
80    public function get($key)
81    {
82        $value = $this->getDriver()->get($key);
83        if ($this->hasMaxSize()) {
84            if ($this->isSplit($value)) {
85                $value = $this->join($key, $value);
86            }
87        }
88        return $value;
89    }
90
91    /**
92     * Check if a key exists
93     * Return false if $key is a mappedKey
94     *
95     * @param $key
96     * @return bool
97     */
98    public function exists($key)
99    {
100        if ($this->isMappedKey($key)) {
101            return false;
102        } else {
103            return $this->getDriver()->exists($key);
104        }
105    }
106
107    /**
108     * Delete a key. If key is split, all associated mapped key are deleted too
109     *
110     * @param $key
111     * @return bool
112     */
113    public function del($key)
114    {
115        if ($this->isMappedKey($key)) {
116            return false;
117        } else {
118            $success = true;
119            if ($this->hasMaxSize()) {
120                $success = $this->deleteMappedKey($key);
121            }
122            return $success && $this->getDriver()->del($key);
123        }
124    }
125
126    /**
127     * Increment $key, only for numeric
128     * Mapped key will be ignored
129     *
130     * @param $key
131     * @return bool|int
132     */
133    public function incr($key)
134    {
135        if ($this->isMappedKey($key)) {
136            return false;
137        }
138        return $this->getDriver()->incr($key);
139    }
140
141    /**
142     * Decrement $key, only for numeric
143     * Mapped key will be ignored
144     *
145     * @param $key
146     * @return bool|int
147     */
148    public function decr($key)
149    {
150        if ($this->isMappedKey($key)) {
151            return false;
152        }
153        return $this->getDriver()->decr($key);
154    }
155
156    /**
157     * Delete a key and if the value is a map, delete all mapped key recursively
158     *
159     * @param $key
160     * @param null $value
161     * @param int $level
162     * @return bool
163     */
164    protected function deleteMappedKey($key, $value = null, $level = 0)
165    {
166        if (is_null($value)) {
167            $value = $this->getDriver()->get($key);
168        }
169
170        if ($level > 0) {
171            $key = $key . self::LEVEL_SEPARATOR . $level;
172        }
173
174        $success = true;
175
176        if ($this->isSplit($value)) {
177            $valueParts = [];
178            foreach ($this->unSerializeMap($value) as $mappedKey) {
179                $mappedKey = $this->transformReferenceToMappedKey($mappedKey);
180                $valueParts[$this->getMappedKeyIndex($mappedKey, $key)] = $this->getDriver()->get($mappedKey);
181                $success = $success && $this->getDriver()->del($mappedKey);
182            }
183
184            uksort($valueParts, 'strnatcmp');
185            $value = implode('', $valueParts);
186            if ($this->isSplit($value)) {
187                $success = $success && $this->deleteMappedKey($key, $value, $level + 1);
188            }
189        }
190        return $success;
191    }
192
193    /**
194     * Purge the Driver if it implements common_persistence_Purgable
195     * Otherwise throws common_exception_NotImplemented
196     *
197     * @return mixed
198     * @throws common_exception_NotImplemented
199     */
200    public function purge()
201    {
202        if ($this->getDriver() instanceof common_persistence_Purgable) {
203            return $this->getDriver()->purge();
204        } else {
205            throw new common_exception_NotImplemented("purge not implemented ");
206        }
207    }
208
209    /**
210     * Set a large value recursively.
211     * Create a map of value (split by size range) and store the serialize map as current value
212     *
213     * @param $key
214     * @param $value
215     * @param int $level
216     * @param bool $flush
217     * @param bool $toTransform
218     * @param null $ttl
219     * @param bool $nx
220     * @return mixed
221     * @throws common_Exception
222     */
223    protected function setLargeValue(
224        $key,
225        $value,
226        $level = 0,
227        $flush = true,
228        $toTransform = true,
229        $ttl = null,
230        $nx = false
231    ) {
232        if (!$this->isLarge($value)) {
233            if ($flush) {
234                $this->set($key, $value, $ttl, $nx);
235            }
236            return $value;
237        }
238        if ($nx) {
239            throw new common_exception_NotImplemented("NX not implemented for large values");
240        }
241
242        if ($level > 0) {
243            $key = $key . self::LEVEL_SEPARATOR . $level;
244        }
245
246        $map = $this->createMap($key, $value);
247        foreach ($map as $mappedKey => $valuePart) {
248            if ($toTransform) {
249                $transformedKey = $this->transformReferenceToMappedKey($mappedKey);
250            } else {
251                $transformedKey = $mappedKey;
252            }
253
254            if (!is_null($ttl)) {
255                $this->set($transformedKey, $valuePart, $ttl);
256            } else {
257                $this->set($transformedKey, $valuePart);
258            }
259        }
260
261        return $this->setLargeValue($key, $this->serializeMap($map), $level + 1, $flush, $toTransform, $ttl);
262    }
263
264    /**
265     * Check if the given $value is larger than $this max size
266     *
267     * @param $value
268     * @return bool
269     * @throws common_Exception If size is misconfigured
270     */
271    protected function isLarge($value)
272    {
273        $size = $this->getSize();
274        if (!$size) {
275            return false;
276        }
277        return strlen($value) > $size;
278    }
279
280    /**
281     * Cut a string into an array with $size option
282     *
283     * @param $value
284     * @return array
285     * @throws common_Exception If size is misconfigured
286     */
287    protected function split($value)
288    {
289        return str_split($value, $this->getSize());
290    }
291
292    /**
293     * Join different values referenced into a map recursively
294     *
295     * @param $key
296     * @param $value
297     * @param int $level
298     * @return string
299     */
300    protected function join($key, $value, $level = 0)
301    {
302        if ($level > 0) {
303            $key = $key . self::LEVEL_SEPARATOR . $level;
304        }
305
306        $valueParts = [];
307        foreach ($this->unSerializeMap($value) as $mappedKey) {
308            $mappedKey = $this->transformReferenceToMappedKey($mappedKey);
309            $valueParts[$this->getMappedKeyIndex($mappedKey, $key)] = $this->getDriver()->get($mappedKey);
310        }
311
312        uksort($valueParts, 'strnatcmp');
313        $value = implode('', $valueParts);
314        if ($this->isSplit($value)) {
315            $value = $this->join($key, $value, $level + 1);
316        }
317        return $value;
318    }
319
320    /**
321     * Split a large value to an array with value size lesser than required max size
322     * Construct the array with index of value
323     *
324     * @param $key
325     * @param $value
326     * @return array
327     * @throws common_Exception If size is misconfigured
328     */
329    protected function createMap($key, $value)
330    {
331        $splitValue = $this->split($value);
332        $map = [];
333        foreach ($splitValue as $index => $part) {
334            $map[$key . self::MAPPED_KEY_SEPARATOR . $index] = $part;
335        }
336        return $map;
337    }
338
339    /**
340     * Transform a map reference to an identifiable $key
341     *
342     * @param $key
343     * @return string
344     */
345    protected function transformReferenceToMappedKey($key)
346    {
347        return $this->getStartMapDelimiter() . $key . $this->getEndMapDelimiter();
348    }
349
350    /**
351     * Check if current $key is part of a map
352     *
353     * @param $key
354     * @return bool
355     */
356    protected function isMappedKey($key)
357    {
358        return substr($key, 0, strlen($this->getStartMapDelimiter())) == $this->getStartMapDelimiter()
359            && substr($key, -strlen($this->getEndMapDelimiter())) == $this->getEndMapDelimiter();
360    }
361
362    /**
363     *  Get the mapped key index of a mappedKey
364     *
365     * @param $mappedKey
366     * @param $key
367     * @return bool|string
368     */
369    protected function getMappedKeyIndex($mappedKey, $key)
370    {
371        $startSize = strlen($this->getStartMapDelimiter()) - 1;
372        $key = substr($key, $startSize, strrpos($key, $this->getEndMapDelimiter()) - $startSize);
373        return substr($mappedKey, strlen($key . self::MAPPED_KEY_SEPARATOR));
374    }
375
376    /**
377     * Serialize a map to set it as a value
378     *
379     * @param array $map
380     * @return string
381     */
382    protected function serializeMap(array $map)
383    {
384        return $this->getMapIdentifier() . json_encode(array_keys($map));
385    }
386
387    /**
388     * Unserialize a map that contains references to mapped keys
389     *
390     * @param $map
391     * @return mixed
392     */
393    protected function unSerializeMap($map)
394    {
395        return json_decode(substr_replace($map, '', 0, strlen($this->getMapIdentifier())), true);
396    }
397
398    /**
399     * Check if the value was split into couple of values.
400     * Identifiable by the map identifier at beginning
401     *
402     * @param $value
403     * @return bool
404     */
405    protected function isSplit($value)
406    {
407        if (!is_string($value)) {
408            return false;
409        }
410        return strpos($value, $this->getMapIdentifier()) === 0;
411    }
412
413    /**
414     * Get the current maximum allowed size for a value
415     *
416     * @return int
417     * @throws common_Exception If size is set
418     */
419    protected function getSize()
420    {
421        if (! $this->size) {
422            $size = $this->getParam(self::MAX_VALUE_SIZE);
423            if ($size !== false) {
424                if (!is_int($size)) {
425                    throw new common_Exception('Persistence max value size has to be an integer');
426                }
427                $this->size = $size - strlen($this->getMapIdentifier());
428            }
429        }
430        return $this->size;
431    }
432
433    /**
434     * Check if the current persistence has a max size parameter
435     *
436     * @return boolean
437     */
438    protected function hasMaxSize()
439    {
440        return $this->getParam(self::MAX_VALUE_SIZE) !== false;
441    }
442
443    /**
444     * Get the identifier to identify a value as split. Should be no used into beginning of none mapped key
445     *
446     * @return string
447     */
448    protected function getMapIdentifier()
449    {
450        return $this->getParam(self::MAP_IDENTIFIER)
451            ?: self::DEFAULT_MAP_IDENTIFIER;
452    }
453
454    /**
455     * Get the start-map delimiter from config, otherwise fallback to default value
456     *
457     * @return string
458     */
459    protected function getStartMapDelimiter()
460    {
461        return $this->getParam(self::START_MAP_DELIMITER)
462            ?: self::DEFAULT_START_MAP_DELIMITER;
463    }
464
465    /**
466     * Get the end-map delimiter from config, otherwise fallback to default value
467     *
468     * @return string
469     */
470    protected function getEndMapDelimiter()
471    {
472        return $this->getParam(self::END_MAP_DELIMITER)
473            ?: self::DEFAULT_END_MAP_DELIMITER;
474    }
475
476    /**
477     * Get the requested param from current parameters, otherwise throws exception
478     *
479     * @param $param
480     * @return mixed
481     */
482    protected function getParam($param)
483    {
484        $params = $this->getParams();
485        if (! isset($params[$param])) {
486            return false;
487        }
488        return $params[$param];
489    }
490
491    /**
492     * Test wheever or not a feature is supported
493     * @param string $feature
494     * @throws common_exception_Error if feature is unkown
495     * @return boolean
496     */
497    public function supportsFeature($feature)
498    {
499        switch ($feature) {
500            case self::FEATURE_NX:
501                return ($this->getDriver() instanceof common_persistence_KeyValue_Nx);
502            default:
503                throw new common_exception_Error('Unknown feature ' . $feature);
504        }
505        return false;
506    }
507}