Passed
Pull Request — master (#2180)
by Arnaud
04:49
created

Cache   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 331
Duplicated Lines 0 %

Test Coverage

Coverage 55.32%

Importance

Changes 11
Bugs 1 Features 1
Metric Value
eloc 130
c 11
b 1
f 1
dl 0
loc 331
ccs 78
cts 141
cp 0.5532
rs 4.5599
wmc 58

19 Methods

Rating   Name   Duplication   Size   Complexity  
A createKey() 0 6 2
A delete() 0 13 2
A set() 0 24 6
A has() 0 8 2
A getMultiple() 0 3 1
B get() 0 36 10
A deleteMultiple() 0 3 1
A setMultiple() 0 3 1
A clear() 0 11 2
A __construct() 0 4 1
A clearByPattern() 0 27 6
A sanitizeKey() 0 9 1
B createKeyFromAsset() 0 27 8
A getContentFilePathname() 0 5 1
A prune() 0 20 6
A getFilePathname() 0 3 1
A duration() 0 10 3
A deleteContentFile() 0 11 2
A createKeyFromFile() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like Cache often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Cache, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Cecil.
7
 *
8
 * Copyright (c) Arnaud Ligny <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Cecil\Assets;
15
16
use Cecil\Builder;
17
use Cecil\Collection\Page\Page;
18
use Cecil\Exception\RuntimeException;
19
use Cecil\Util;
20
use Psr\SimpleCache\CacheInterface;
21
22
class Cache implements CacheInterface
23
{
24
    /** @var Builder */
25
    protected $builder;
26
27
    /** @var string */
28
    protected $cacheDir;
29
30 1
    public function __construct(Builder $builder, string $pool = '')
31
    {
32 1
        $this->builder = $builder;
33 1
        $this->cacheDir = Util::joinFile($builder->getConfig()->getCachePath(), $pool);
34
    }
35
36
    /**
37
     * {@inheritdoc}
38
     */
39 1
    public function set($key, $value, $ttl = null): bool
40
    {
41
        try {
42 1
            $key = self::sanitizeKey($key);
43 1
            $this->prune($key);
44
            // put file content in a dedicated file
45 1
            if (\is_array($value) && !empty($value['content']) && !empty($value['path'])) {
46 1
                Util\File::getFS()->dumpFile($this->getContentFilePathname($value['path']), $value['content']);
47 1
                unset($value['content']);
48
            }
49
            // serialize data
50 1
            $data = serialize([
51 1
                'value'      => $value,
52 1
                'expiration' => $ttl === null ? null : time() + $this->duration($ttl),
53 1
            ]);
54 1
            Util\File::getFS()->dumpFile($this->getFilePathname($key), $data);
55 1
            $this->builder->getLogger()->debug(\sprintf('Cache created: "%s"', Util\File::getFS()->makePathRelative($this->getFilePathname($key), $this->builder->getConfig()->getCachePath())));
56
        } catch (\Exception $e) {
57
            $this->builder->getLogger()->error($e->getMessage());
58
59
            return false;
60
        }
61
62 1
        return true;
63
    }
64
65
    /**
66
     * {@inheritdoc}
67
     */
68 1
    public function has($key): bool
69
    {
70 1
        $key = self::sanitizeKey($key);
71 1
        if (!Util\File::getFS()->exists($this->getFilePathname($key))) {
72 1
            return false;
73
        }
74
75 1
        return true;
76
    }
77
78
    /**
79
     * {@inheritdoc}
80
     */
81 1
    public function get($key, $default = null): mixed
82
    {
83
        try {
84 1
            $key = self::sanitizeKey($key);
85
            // return default value if file doesn't exists
86 1
            if (false === $content = Util\File::fileGetContents($this->getFilePathname($key))) {
87
                return $default;
88
            }
89
            // unserialize data
90 1
            $data = unserialize($content);
91
            // check expiration
92 1
            if ($data['expiration'] !== null && $data['expiration'] <= time()) {
93
                $this->builder->getLogger()->debug(\sprintf('Cache expired: "%s"', $key));
94
                // remove expired cache
95
                if ($this->delete($key)) {
96
                    // remove content file if exists
97
                    if (!empty($data['value']['path'])) {
98
                        $this->deleteContentFile($data['value']['path']);
99
                    }
100
                }
101
102
                return $default;
103
            }
104
            // get content from dedicated file
105 1
            if (\is_array($data['value']) && isset($data['value']['path'])) {
106 1
                if (false !== $content = Util\File::fileGetContents($this->getContentFilePathname($data['value']['path']))) {
107 1
                    $data['value']['content'] = $content;
108
                }
109
            }
110
        } catch (\Exception $e) {
111
            $this->builder->getLogger()->error($e->getMessage());
112
113
            return $default;
114
        }
115
116 1
        return $data['value'];
117
    }
118
119
    /**
120
     * {@inheritdoc}
121
     */
122
    public function delete($key): bool
123
    {
124
        try {
125
            $key = self::sanitizeKey($key);
126
            Util\File::getFS()->remove($this->getFilePathname($key));
127
            $this->prune($key);
128
        } catch (\Exception $e) {
129
            $this->builder->getLogger()->error($e->getMessage());
130
131
            return false;
132
        }
133
134
        return true;
135
    }
136
137
    /**
138
     * {@inheritdoc}
139
     */
140
    public function clear(): bool
141
    {
142
        try {
143
            Util\File::getFS()->remove($this->cacheDir);
144
        } catch (\Exception $e) {
145
            $this->builder->getLogger()->error($e->getMessage());
146
147
            return false;
148
        }
149
150
        return true;
151
    }
152
153
    /**
154
     * {@inheritdoc}
155
     */
156
    public function getMultiple($keys, $default = null): iterable
157
    {
158
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
159
    }
160
161
    /**
162
     * {@inheritdoc}
163
     */
164
    public function setMultiple($values, $ttl = null): bool
165
    {
166
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
167
    }
168
169
    /**
170
     * {@inheritdoc}
171
     */
172
    public function deleteMultiple($keys): bool
173
    {
174
        throw new \Exception(\sprintf('%s::%s not yet implemented.', __CLASS__, __FUNCTION__));
175
    }
176
177
    /**
178
     * Creates key from a string: "$name|uniqid__HASH__VERSION".
179
     * $name is optional to add a human readable name to the key.
180
     */
181 1
    public function createKey(?string $name, string $value): string
182
    {
183 1
        $hash = hash('md5', $value);
184 1
        $name = $name ? self::sanitizeKey($name) : $hash;
185
186 1
        return \sprintf('%s__%s__%s', $name, $hash, $this->builder->getVersion());
187
    }
188
189
    /**
190
     * Creates key from an Asset: "$path_$ext_$tags__HASH__VERSION".
191
     */
192 1
    public function createKeyFromAsset(Asset $asset, ?array $tags = null): string
193
    {
194 1
        $t = $tags;
195 1
        $tags = [];
196
197 1
        if ($t !== null) {
198 1
            foreach ($t as $key => $value) {
199 1
                switch (\gettype($value)) {
200 1
                    case 'boolean':
201 1
                        if ($value === true) {
202 1
                            $tags[] = $key;
203
                        }
204 1
                        break;
205 1
                    case 'string':
206 1
                    case 'integer':
207 1
                        if (!empty($value)) {
208 1
                            $tags[] = substr($key, 0, 1) . $value;
209
                        }
210 1
                        break;
211
                }
212
            }
213
        }
214
215 1
        $tagsInline = implode('_', str_replace('_', '', $tags));
216 1
        $name = "{$asset['_path']}_{$asset['ext']}_$tagsInline";
217
218 1
        return $this->createKey($name, $asset['content'] ?? '');
219
    }
220
221
    /**
222
     * Creates key from a file: "RelativePathname__MD5".
223
     *
224
     * @throws RuntimeException
225
     */
226 1
    public function createKeyFromFile(\Symfony\Component\Finder\SplFileInfo $file): string
227
    {
228 1
        if (false === $content = Util\File::fileGetContents($file->getRealPath())) {
229
            throw new RuntimeException(\sprintf('Can\'t create cache key for "%s".', $file));
230
        }
231
232 1
        return $this->createKey($file->getRelativePathname(), $content);
233
    }
234
235
    /**
236
     * Clear cache by pattern.
237
     */
238
    public function clearByPattern(string $pattern): int
239
    {
240
        try {
241
            if (!Util\File::getFS()->exists($this->cacheDir)) {
242
                throw new RuntimeException(\sprintf('The cache directory "%s" does not exists.', $this->cacheDir));
243
            }
244
            $fileCount = 0;
245
            $iterator = new \RecursiveIteratorIterator(
246
                new \RecursiveDirectoryIterator($this->cacheDir),
247
                \RecursiveIteratorIterator::SELF_FIRST
248
            );
249
            foreach ($iterator as $file) {
250
                if ($file->isFile()) {
251
                    if (preg_match('/' . $pattern . '/i', $file->getPathname())) {
252
                        Util\File::getFS()->remove($file->getPathname());
253
                        $fileCount++;
254
                        $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($file->getPathname(), $this->builder->getConfig()->getCachePath())));
255
                    }
256
                }
257
            }
258
        } catch (\Exception $e) {
259
            $this->builder->getLogger()->error($e->getMessage());
260
261
            return 0;
262
        }
263
264
        return $fileCount;
265
    }
266
267
    /**
268
     * Returns cache content file pathname from path.
269
     */
270 1
    public function getContentFilePathname(string $path): string
271
    {
272 1
        $path = str_replace(['https://', 'http://'], '', $path); // remove protocol (if URL)
273
274 1
        return Util::joinFile($this->cacheDir, 'files', $path);
275
    }
276
277
    /**
278
     * Returns cache file pathname from key.
279
     */
280 1
    private function getFilePathname(string $key): string
281
    {
282 1
        return Util::joinFile($this->cacheDir, "$key.ser");
283
    }
284
285
    /**
286
     * Prepares and validate $key.
287
     */
288 1
    public static function sanitizeKey(string $key): string
289
    {
290 1
        $key = str_replace(['https://', 'http://'], '', $key); // remove protocol (if URL)
291 1
        $key = Page::slugify($key);                            // slugify
292 1
        $key = trim($key, '/');                                // remove leading/trailing slashes
293 1
        $key = str_replace(['\\', '/'], ['-', '-'], $key);     // replace slashes by hyphens
294 1
        $key = substr($key, 0, 200);                           // truncate to 200 characters (NTFS filename length limit is 255 characters)
295
296 1
        return $key;
297
    }
298
299
    /**
300
     * Removes previous cache files.
301
     */
302 1
    private function prune(string $key): bool
303
    {
304
        try {
305 1
            $keyAsArray = explode('__', self::sanitizeKey($key));
306
            // if 2 or more parts (with hash), remove all files with the same first part
307
            // pattern: `name__hash__version`
308 1
            if (!empty($keyAsArray[0]) && \count($keyAsArray) >= 2) {
309 1
                $pattern = Util::joinFile($this->cacheDir, $keyAsArray[0]) . '*';
310 1
                foreach (glob($pattern) ?: [] as $filename) {
311 1
                    Util\File::getFS()->remove($filename);
312 1
                    $this->builder->getLogger()->debug(\sprintf('Cache removed: "%s"', Util\File::getFS()->makePathRelative($filename, $this->builder->getConfig()->getCachePath())));
313
                }
314
            }
315
        } catch (\Exception $e) {
316
            $this->builder->getLogger()->error($e->getMessage());
317
318
            return false;
319
        }
320
321 1
        return true;
322
    }
323
324
    /**
325
     * Convert the various expressions of a TTL value into duration in seconds.
326
     */
327 1
    protected function duration(int|\DateInterval $ttl): int
328
    {
329 1
        if (\is_int($ttl)) {
330 1
            return $ttl;
331
        }
332
        if ($ttl instanceof \DateInterval) {
333
            return (int) $ttl->d * 86400 + $ttl->h * 3600 + $ttl->i * 60 + $ttl->s;
334
        }
335
336
        throw new \InvalidArgumentException('TTL values must be int or \DateInterval');
337
    }
338
339
    /**
340
     * Removes the cache content file.
341
     */
342
    protected function deleteContentFile(string $path): bool
343
    {
344
        try {
345
            Util\File::getFS()->remove($this->getContentFilePathname($path));
346
        } catch (\Exception $e) {
347
            $this->builder->getLogger()->error($e->getMessage());
348
349
            return false;
350
        }
351
352
        return true;
353
    }
354
}
355