Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.25% covered (success)
99.25%
263 / 265
95.65% covered (success)
95.65%
22 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
FileCache
99.25% covered (success)
99.25%
263 / 265
95.65% covered (success)
95.65%
22 / 23
126
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 flushAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 stats
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getThreshold
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 add
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 stringify
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 getExpire
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 reverseValue
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
13
 delete
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 exists
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 increment
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 decrement
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 rename
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getLike
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
11
 deleteLike
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
11
 updateExpire
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 flush
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 replace
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\DataStorage\Cache\Connection
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\DataStorage\Cache\Connection;
16
17use phpOMS\DataStorage\Cache\CacheStatus;
18use phpOMS\DataStorage\Cache\CacheType;
19use phpOMS\DataStorage\Cache\Exception\InvalidConnectionConfigException;
20use phpOMS\Stdlib\Base\Exception\InvalidEnumValue;
21use phpOMS\System\File\Local\Directory;
22use phpOMS\System\File\Local\File;
23
24/**
25 * File cache.
26 *
27 * This implementation uses the hard drive as cache by saving data to the disc as text files.
28 * The text files follow a defined strucuture which allows this implementation to parse the cached data.
29 *
30 * Allowed datatypes: null, int, bool, float, string, \DateTime, \JsonSerializable, \Serializable
31 * File structure:
32 *      data type (1 byte)
33 *      delimiter (1 byte)
34 *      expiration duration in seconds (1 - n bytes) (based on the file creation date)
35 *      delimiter (1 byte)
36 *      data (n bytes)
37 *
38 * @package phpOMS\DataStorage\Cache\Connection
39 * @license OMS License 2.0
40 * @link    https://jingga.app
41 * @since   1.0.0
42 */
43final class FileCache extends ConnectionAbstract
44{
45    /**
46     * {@inheritdoc}
47     */
48    protected string $type = CacheType::FILE;
49
50    /**
51     * Delimiter for cache meta data
52     *
53     * @var string
54     * @since 1.0.0
55     */
56    private const DELIM = '$';
57
58    /**
59     * File path sanitizer
60     *
61     * @var string
62     * @since 1.0.0
63     */
64    private const SANITIZE = '~';
65
66    /**
67     * Only cache if data is larger than threshold (0-100).
68     *
69     * @var int
70     * @since 1.0.0
71     */
72    private int $threshold = 50;
73
74    /**
75     * Constructor
76     *
77     * @param string $path Cache path
78     *
79     * @since 1.0.0
80     */
81    public function __construct(string $path)
82    {
83        $this->connect([$path]);
84    }
85
86    /**
87     * Connect to cache
88     *
89     * @param null|array{0:string} $data Cache data (path to cache directory)
90     *
91     * @return void
92     *
93     * @throws InvalidConnectionConfigException
94     *
95     * @since 1.0.0
96     */
97    public function connect(array $data = null) : void
98    {
99        $this->dbdata = $data;
100
101        if (!Directory::exists($data[0])) {
102            Directory::create($data[0], 0766, true);
103        }
104
105        if (\realpath($data[0]) === false) {
106            $this->status = CacheStatus::FAILURE;
107            throw new InvalidConnectionConfigException((string) \json_encode($this->dbdata));
108        }
109
110        $this->status = CacheStatus::OK;
111        $this->con    = \realpath($data[0]);
112    }
113
114    /**
115     * {@inheritdoc}
116     */
117    public function flushAll() : bool
118    {
119        if ($this->status !== CacheStatus::OK) {
120            return false;
121        }
122
123        \array_map('unlink', \glob($this->con . '/*'));
124
125        return true;
126    }
127
128    /**
129     * {@inheritdoc}
130     */
131    public function stats() : array
132    {
133        if ($this->status !== CacheStatus::OK) {
134            return [];
135        }
136
137        $stats           = [];
138        $stats['status'] = $this->status;
139        $stats['count']  = Directory::count($this->con);
140        $stats['size']   = Directory::size($this->con);
141
142        return $stats;
143    }
144
145    /**
146     * {@inheritdoc}
147     */
148    public function getThreshold() : int
149    {
150        return $this->threshold;
151    }
152
153    /**
154     * {@inheritdoc}
155     */
156    public function set(int | string $key, mixed $value, int $expire = -1) : void
157    {
158        if ($this->status !== CacheStatus::OK) {
159            return;
160        }
161
162        $path = Directory::sanitize((string) $key, self::SANITIZE);
163
164        $fp = \fopen($this->con . '/' . \trim($path, '/') . '.cache', 'w+');
165        if (\flock($fp, \LOCK_EX)) {
166            \ftruncate($fp, 0);
167            \fwrite($fp, $this->build($value, $expire));
168            \fflush($fp);
169            \flock($fp, \LOCK_UN);
170        }
171        \fclose($fp);
172    }
173
174    /**
175     * {@inheritdoc}
176     */
177    public function add(int | string $key, mixed $value, int $expire = -1) : bool
178    {
179        if ($this->status !== CacheStatus::OK) {
180            return false;
181        }
182
183        $path = $this->getPath($key);
184
185        if (!File::exists($path)) {
186            $fp = \fopen($path, 'w+');
187            if (\flock($fp, \LOCK_EX)) {
188                \ftruncate($fp, 0);
189                \fwrite($fp, $this->build($value, $expire));
190                \fflush($fp);
191                \flock($fp, \LOCK_UN);
192            }
193            \fclose($fp);
194
195            return true;
196        }
197
198        return false;
199    }
200
201    /**
202     * Removing all cache elements larger or equal to the expiration date. Call flushAll for removing persistent cache elements (expiration is negative) as well.
203     *
204     * @param mixed $value  Data to cache
205     * @param int   $expire Expire date of the cached data
206     *
207     * @return string
208     *
209     * @since 1.0.0
210     */
211    private function build(mixed $value, int $expire) : string
212    {
213        $type = $this->dataType($value);
214        $raw  = $this->stringify($value, $type);
215
216        return $type . self::DELIM . $expire . self::DELIM . $raw;
217    }
218
219    /**
220     * Create string representation of data for storage
221     *
222     * @param mixed $value Value of the data
223     * @param int   $type  Type of the cache data
224     *
225     * @return string
226     *
227     * @throws InvalidEnumValue This exception is thrown if an unsupported cache value type is used
228     *
229     * @since 1.0.0
230     */
231    private function stringify(mixed $value, int $type) : string
232    {
233        if ($type === CacheValueType::_INT || $type === CacheValueType::_STRING || $type === CacheValueType::_BOOL) {
234            return (string) $value;
235        } elseif ($type === CacheValueType::_FLOAT) {
236            return \rtrim(\rtrim(\number_format($value, 5, '.', ''), '0'), '.');
237        } elseif ($type === CacheValueType::_ARRAY) {
238            return (string) \json_encode($value);
239        } elseif ($type === CacheValueType::_SERIALIZABLE) {
240            return \get_class($value) . self::DELIM . $value->serialize();
241        } elseif ($type === CacheValueType::_JSONSERIALIZABLE) {
242            return \get_class($value) . self::DELIM . ((string) \json_encode($value->jsonSerialize()));
243        } elseif ($type === CacheValueType::_NULL) {
244            return '';
245        }
246
247        throw new InvalidEnumValue($type); // @codeCoverageIgnore
248    }
249
250    /**
251     * Get expire offset
252     *
253     * @param string $raw Raw data
254     *
255     * @return int
256     *
257     * @since 1.0.0
258     */
259    private function getExpire(string $raw) : int
260    {
261        $expireStart = (int) \strpos($raw, self::DELIM);
262        $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
263
264        return (int) \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
265    }
266
267    /**
268     * {@inheritdoc}
269     */
270    public function get(int | string $key, int $expire = -1) : mixed
271    {
272        if ($this->status !== CacheStatus::OK) {
273            return null;
274        }
275
276        $path = $this->getPath($key);
277        if (!File::exists($path)) {
278            return null;
279        }
280
281        $created = File::created($path)->getTimestamp();
282        $now     = \time();
283
284        if ($expire >= 0 && $created + $expire < $now) {
285            return null;
286        }
287
288        $raw = \file_get_contents($path);
289        if ($raw === false) {
290            return null; // @codeCoverageIgnore
291        }
292
293        $type        = (int) $raw[0];
294        $expireStart = (int) \strpos($raw, self::DELIM);
295        $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
296
297        if ($expireStart < 0 || $expireEnd < 0) {
298            return null; // @codeCoverageIgnore
299        }
300
301        $cacheExpire = \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
302        $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
303
304        if ($cacheExpire >= 0 && $created + $cacheExpire + \max(0, $expire) < $now) {
305            $this->delete($key);
306
307            return null;
308        }
309
310        return $this->reverseValue($type, $raw, $expireEnd);
311    }
312
313    /**
314     * Parse cached value
315     *
316     * @param int    $type      Cached value type
317     * @param string $raw       Cached value
318     * @param int    $expireEnd Value end position
319     *
320     * @return mixed
321     *
322     * @since 1.0.0
323     */
324    private function reverseValue(int $type, string $raw, int $expireEnd) : mixed
325    {
326        switch ($type) {
327            case CacheValueType::_INT:
328                return (int) \substr($raw, $expireEnd + 1);
329            case CacheValueType::_FLOAT:
330                return (float) \substr($raw, $expireEnd + 1);
331            case CacheValueType::_BOOL:
332                return (bool) \substr($raw, $expireEnd + 1);
333            case CacheValueType::_STRING:
334                return \substr($raw, $expireEnd + 1);
335            case CacheValueType::_ARRAY:
336                $array = \substr($raw, $expireEnd + 1);
337                return \json_decode($array === false ? '[]' : $array, true);
338            case CacheValueType::_NULL:
339                return null;
340            case CacheValueType::_JSONSERIALIZABLE:
341                $namespaceStart = (int) \strpos($raw, self::DELIM, $expireEnd);
342                $namespaceEnd   = (int) \strpos($raw, self::DELIM, $namespaceStart + 1);
343                $namespace      = \substr($raw, $namespaceStart + 1, $namespaceEnd - $namespaceStart - 1);
344
345                if ($namespace === false) {
346                    return null; // @codeCoverageIgnore
347                }
348
349                return new $namespace();
350            case CacheValueType::_SERIALIZABLE:
351                $namespaceStart = (int) \strpos($raw, self::DELIM, $expireEnd);
352                $namespaceEnd   = (int) \strpos($raw, self::DELIM, $namespaceStart + 1);
353                $namespace      = \substr($raw, $namespaceStart + 1, $namespaceEnd - $namespaceStart - 1);
354
355                if ($namespace === false) {
356                    return null; // @codeCoverageIgnore
357                }
358
359                $obj = new $namespace();
360                $obj->unserialize(\substr($raw, $namespaceEnd + 1));
361
362                return $obj;
363            default:
364                return null; // @codeCoverageIgnore
365        }
366    }
367
368    /**
369     * {@inheritdoc}
370     */
371    public function delete(int | string $key, int $expire = -1) : bool
372    {
373        if ($this->status !== CacheStatus::OK) {
374            return false;
375        }
376
377        $path = $this->getPath($key);
378        if (!File::exists($path)) {
379            return true;
380        }
381
382        if ($expire < 0) {
383            File::delete($path);
384
385            return true;
386        }
387
388        if ($expire >= 0) {
389            $created = File::created($path)->getTimestamp();
390            $now     = \time();
391            $raw     = \file_get_contents($path);
392
393            if ($raw === false) {
394                return false; // @codeCoverageIgnore
395            }
396
397            $cacheExpire = $this->getExpire($raw);
398            $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
399
400            if (($cacheExpire >= 0 && $created + $cacheExpire < $now)
401                || ($cacheExpire >= 0 && \abs($now - $created) > $expire)
402            ) {
403                File::delete($path);
404
405                return true;
406            }
407        }
408
409        return false;
410    }
411
412    /**
413     * {@inheritdoc}
414     */
415    public function exists(int | string $key, int $expire = -1) : bool
416    {
417        if ($this->status !== CacheStatus::OK) {
418            return false;
419        }
420
421        $path = $this->getPath($key);
422        if (!File::exists($path)) {
423            return false;
424        }
425
426        $created = File::created($path)->getTimestamp();
427        $now     = \time();
428
429        if ($expire >= 0 && $created + $expire < $now) {
430            return false;
431        }
432
433        $raw = \file_get_contents($path);
434        if ($raw === false) {
435            return false; // @codeCoverageIgnore
436        }
437
438        $expireStart = (int) \strpos($raw, self::DELIM);
439        $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
440
441        if ($expireStart < 0 || $expireEnd < 0) {
442            return false; // @codeCoverageIgnore
443        }
444
445        $cacheExpire = \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
446        $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
447
448        if ($cacheExpire >= 0 && $created + $cacheExpire + \max(0, $expire) < $now) {
449            File::delete($path);
450
451            return false;
452        }
453
454        return true;
455    }
456
457    /**
458     * {@inheritdoc}
459     */
460    public function increment(int | string $key, int $value = 1) : bool
461    {
462        if ($this->status !== CacheStatus::OK) {
463            return false;
464        }
465
466        $path = $this->getPath($key);
467        if (!File::exists($path)) {
468            return false;
469        }
470
471        $created = File::created($path)->getTimestamp();
472
473        $raw = \file_get_contents($path);
474        if ($raw === false) {
475            return false; // @codeCoverageIgnore
476        }
477
478        $type        = (int) $raw[0];
479        $expireStart = (int) \strpos($raw, self::DELIM);
480        $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
481
482        if ($expireStart < 0 || $expireEnd < 0) {
483            return false; // @codeCoverageIgnore
484        }
485
486        $cacheExpire = \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
487        $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
488
489        $val = $this->reverseValue($type, $raw, $expireEnd);
490        $this->set($key, $val + $value, $cacheExpire);
491
492        return true;
493    }
494
495    /**
496     * {@inheritdoc}
497     */
498    public function decrement(int | string $key, int $value = 1) : bool
499    {
500        if ($this->status !== CacheStatus::OK) {
501            return false;
502        }
503
504        $path = $this->getPath($key);
505        if (!File::exists($path)) {
506            return false;
507        }
508
509        $created = File::created($path)->getTimestamp();
510
511        $raw = \file_get_contents($path);
512        if ($raw === false) {
513            return false; // @codeCoverageIgnore
514        }
515
516        $type        = (int) $raw[0];
517        $expireStart = (int) \strpos($raw, self::DELIM);
518        $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
519
520        if ($expireStart < 0 || $expireEnd < 0) {
521            return false; // @codeCoverageIgnore
522        }
523
524        $cacheExpire = \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
525        $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
526
527        $val = $this->reverseValue($type, $raw, $expireEnd);
528        $this->set($key, $val - $value, $cacheExpire);
529
530        return true;
531    }
532
533    /**
534     * {@inheritdoc}
535     */
536    public function rename(int | string $old, int | string $new, int $expire = -1) : bool
537    {
538        if ($this->status !== CacheStatus::OK) {
539            return false;
540        }
541
542        $value = $this->get($old);
543        $this->set($new, $value, $expire);
544        $this->delete($old);
545
546        return true;
547    }
548
549    /**
550     * {@inheritdoc}
551     */
552    public function getLike(string $pattern, int $expire = -1) : array
553    {
554        if ($this->status !== CacheStatus::OK) {
555            return [];
556        }
557
558        $files  = Directory::list($this->con . '/', $pattern . '\.cache', true);
559        $values = [];
560
561        foreach ($files as $path) {
562            $path    = $this->con . '/' . $path;
563            $created = File::created($path)->getTimestamp();
564            $now     = \time();
565
566            if ($expire >= 0 && $created + $expire < $now) {
567                continue;
568            }
569
570            $raw = \file_get_contents($path);
571            if ($raw === false) {
572                continue; // @codeCoverageIgnore
573            }
574
575            $type        = (int) $raw[0];
576            $expireStart = (int) \strpos($raw, self::DELIM);
577            $expireEnd   = (int) \strpos($raw, self::DELIM, $expireStart + 1);
578
579            if ($expireStart < 0 || $expireEnd < 0) {
580                continue; // @codeCoverageIgnore
581            }
582
583            $cacheExpire = \substr($raw, $expireStart + 1, $expireEnd - ($expireStart + 1));
584            $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
585
586            if ($cacheExpire >= 0 && $created + $cacheExpire + \max(0, $expire) < $now) {
587                File::delete($path);
588
589                continue;
590            }
591
592            $values[] = $this->reverseValue($type, $raw, $expireEnd);
593        }
594
595        return $values;
596    }
597
598    /**
599     * {@inheritdoc}
600     */
601    public function deleteLike(string $pattern, int $expire = -1) : bool
602    {
603        if ($this->status !== CacheStatus::OK) {
604            return false;
605        }
606
607        $files = Directory::list($this->con . '/', $pattern . '\.cache', true);
608
609        foreach ($files as $path) {
610            $path = $this->con . '/' . $path;
611
612            if ($expire < 0) {
613                File::delete($path);
614
615                continue;
616            }
617
618            if ($expire >= 0) {
619                $created = File::created($path)->getTimestamp();
620                $now     = \time();
621                $raw     = \file_get_contents($path);
622
623                if ($raw === false) {
624                    continue; // @codeCoverageIgnore
625                }
626
627                $cacheExpire = $this->getExpire($raw);
628                $cacheExpire = ($cacheExpire === -1) ? $created : (int) $cacheExpire;
629
630                if (($cacheExpire >= 0 && $created + $cacheExpire < $now)
631                    || ($cacheExpire >= 0 && \abs($now - $created) > $expire)
632                ) {
633                    File::delete($path);
634
635                    continue;
636                }
637            }
638        }
639
640        return true;
641    }
642
643    /**
644     * {@inheritdoc}
645     */
646    public function updateExpire(int | string $key, int $expire = -1) : bool
647    {
648        if ($this->status !== CacheStatus::OK) {
649            return false;
650        }
651
652        $value = $this->get($key, $expire);
653        $this->delete($key);
654        $this->set($key, $value, $expire);
655
656        return true;
657    }
658
659    /**
660     * {@inheritdoc}
661     */
662    public function flush(int $expire = 0) : bool
663    {
664        if ($this->status !== CacheStatus::OK) {
665            return false;
666        }
667
668        $dir = new Directory($this->con);
669        $now = \time();
670
671        foreach ($dir as $file) {
672            if ($file instanceof File) {
673                $created = $file->createdAt->getTimestamp();
674                if (($expire >= 0 && $created + $expire < $now)
675                    || ($expire < 0 && $created + $this->getExpire($file->getContent()) < $now)
676                ) {
677                    File::delete($file->getPath());
678                }
679            }
680        }
681
682        return true;
683    }
684
685    /**
686     * {@inheritdoc}
687     */
688    public function replace(int | string $key, mixed $value, int $expire = -1) : bool
689    {
690        if ($this->status !== CacheStatus::OK) {
691            return false;
692        }
693
694        $path = $this->getPath($key);
695
696        if (File::exists($path)) {
697            $fp = \fopen($path, 'w+');
698            if (\flock($fp, \LOCK_EX)) {
699                \ftruncate($fp, 0);
700                \fwrite($fp, $this->build($value, $expire));
701                \fflush($fp);
702                \flock($fp, \LOCK_UN);
703            }
704            \fclose($fp);
705
706            return true;
707        }
708
709        return false;
710    }
711
712    /**
713     * Get cache path
714     *
715     * @param mixed $key Key for cached value
716     *
717     * @return string Path to cache file
718     *
719     * @since 1.0.0
720     */
721    private function getPath(int | string $key) : string
722    {
723        $path = Directory::sanitize((string) $key, self::SANITIZE);
724        return $this->con . '/' . \trim($path, '/') . '.cache';
725    }
726}