Passed
Pull Request — master (#332)
by
unknown
06:41
created

FileStore::getActualCacheKey()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 4
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 9
ccs 1
cts 1
cp 1
crap 2
rs 10
1
<?php
2
3
namespace TusPhp\Cache;
4
5
use Carbon\Carbon;
6
use TusPhp\Config;
7
8
class FileStore extends AbstractCache
9
{
10
    /** @var string */
11
    protected $cacheDir;
12
13
    /** @var string */
14
    protected $cacheFile;
15
16
    /**
17
     * FileStore constructor.
18
     *
19
     * @param string|null $cacheDir
20
     * @param string|null $cacheFile
21
     */
22
    public function __construct(string $cacheDir = null, string $cacheFile = null)
23 3
    {
24
        $cacheDir  = $cacheDir ?? Config::get('file.dir');
25 3
        $cacheFile = $cacheFile ?? Config::get('file.name');
26 3
27
        $this->setCacheDir($cacheDir);
28 3
        $this->setCacheFile($cacheFile);
29 3
    }
30 3
31
    /**
32
     * Set cache dir.
33
     *
34
     * @param string $path
35
     *
36
     * @return self
37
     */
38
    public function setCacheDir(string $path): self
39 2
    {
40
        $this->cacheDir = $path;
41 2
42
        return $this;
43 2
    }
44
45
    /**
46
     * Get cache dir.
47
     *
48
     * @return string
49
     */
50
    public function getCacheDir(): string
51 1
    {
52
        return $this->cacheDir;
53 1
    }
54
55
    /**
56
     * Set cache file.
57
     *
58
     * @param string $file
59
     *
60
     * @return self
61
     */
62
    public function setCacheFile(string $file): self
63 2
    {
64
        $this->cacheFile = $file;
65 2
66
        return $this;
67 2
    }
68
69
    /**
70
     * Get cache file.
71
     *
72
     * @return string
73
     */
74
    public function getCacheFile(): string
75 5
    {
76
        return $this->cacheDir . $this->cacheFile;
77 5
    }
78
79
    /**
80
     * Create cache dir if not exists.
81
     *
82
     * @return void
83
     */
84
    protected function createCacheDir()
85 2
    {
86
        if ( ! file_exists($this->cacheDir)) {
87 2
            mkdir($this->cacheDir);
88 2
        }
89
    }
90 2
91
    /**
92
     * Create a cache file.
93
     *
94
     * @return void
95
     */
96
    protected function createCacheFile()
97 2
    {
98
        $this->createCacheDir();
99 2
100
        $cacheFilePath = $this->getCacheFile();
101 2
102
        if ( ! file_exists($cacheFilePath)) {
103 2
            touch($cacheFilePath);
104 2
        }
105
    }
106 2
107
    /**
108
     * {@inheritDoc}
109
     */
110
    public function get(string $key, bool $withExpired = false)
111 8
    {
112
        $key = $this->getActualCacheKey($key);
113 8
114 8
        return $this->lockedGet(
115
            $this->getCacheFile(),
116 8
            false,
117 3
            function ($handler, array $content) use ($withExpired, $key) {
118
                if (empty($content[$key])) {
119
                    return null;
120 5
                }
121 1
122
                $meta = $content[$key];
123
124 5
                if ( ! $withExpired && ! $this->isValid($meta)) {
125
                    return null;
126
                }
127
128
                return $meta;
129
            }
130
        );
131
    }
132
133
    /**
134 2
     * Write the contents of a file with exclusive lock.
135
     *
136 2
     * It is not recommended to use this method, for updating files as it will nullify any changes that have been made
137 2
     * to the file between retrieving $contents and writing the changes. As such, one should instead use lockedSet.
138
     *
139 2
     * @param string $path
140 1
     * @param string $contents
141
     *
142
     * @return int|bool
143
     * @see LockedFileStore::lockedSet
144 1
     *
145 1
     * @deprecated It is not recommended to use this method, use `lockedSet` instead.
146
     *
147 1
148
     */
149 1
    public function put(string $path, string $contents): int
150
    {
151 1
        return $this->lockedSet(
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->lockedSet(...ion(...) { /* ... */ }) could return the type boolean which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
152 1
            $path,
153
            function ($data) use ($contents) {
0 ignored issues
show
Unused Code introduced by
The parameter $data is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

153
            function (/** @scrutinizer ignore-unused */ $data) use ($contents) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
154
                return $contents;
155 1
            }
156
        );
157
    }
158
159
    /**
160
     * {@inheritDoc}
161
     */
162
    public function set(string $key, $value)
163
    {
164
        $cacheKey  = $this->getActualCacheKey($key);
165
        $cacheFile = $this->getCacheFile();
166 3
167
        if ( ! file_exists($cacheFile)) {
168 3
            $this->createCacheFile();
169
        }
170
171
        $this->lockedSet($cacheFile, function (array $data) use ($value, $cacheKey) {
172
            if ( ! empty($data[$cacheKey]) && \is_array($value)) {
173
                $data[$cacheKey] = $value + $data[$cacheKey];
174 8
            } else {
175
                $data[$cacheKey] = $value;
176 8
            }
177 8
178
            return $data;
179 8
        });
180 8
    }
181
182
    /**
183 8
     * {@inheritDoc}
184
     */
185 8
    public function delete(string $key): bool
186 3
    {
187
        $cacheKey = $this->getActualCacheKey($key);
188 8
        $deletion = false;
189
190
        return $this->lockedSet(
191 8
            $this->getCacheFile(),
192
            function ($data) use ($cacheKey, &$deletion) {
193
                if (isset($data[$cacheKey])) {
194
                    unset($data[$cacheKey]);
195
                    $deletion = true;
196
                }
197 1
198
                return $data;
199 1
            }
200 1
        ) !== false && $deletion;
201
    }
202 1
203 1
    /**
204
     * {@inheritDoc}
205 1
     */
206
    public function keys() : array
207
    {
208 1
        $contents = $this->getCacheContents();
209
210
        if (\is_array($contents)) {
211
            return array_keys($contents);
212
        }
213
214 2
        return [];
215
    }
216 2
217
    /**
218 2
     * Check if cache is still valid.
219 1
     *
220
     * @param string|array $meta The cache key, or the metadata object
221
     * @return bool
222 1
     */
223
    public function isValid($meta): bool
224
    {
225
        if ( ! \is_array($meta)) {
226
            $key  = $this->getActualCacheKey($meta);
227
            $meta = $this->lockedGet($this->getCacheFile())[$key] ?? [];
228
        }
229
230
        if (empty($meta['expires_at'])) {
231
            return false;
232 3
        }
233
234 3
        return Carbon::now() < Carbon::createFromFormat(self::RFC_7231, $meta['expires_at']);
235 3
    }
236
237 3
    /**
238 1
     * Get cache contents.
239
     *
240
     * @return array|bool
241 2
     */
242
    public function getCacheContents()
243
    {
244
        $cacheFile = $this->getCacheFile();
245
246
        if ( ! file_exists($cacheFile)) {
247
            return false;
248
        }
249 3
250
        return $this->lockedGet($cacheFile);
251 3
    }
252
253 3
    /**
254 1
     * Get actual cache key with prefix.
255
     *
256
     * @param string $key
257 2
     *
258
     * @return string
259
     */
260
    public function getActualCacheKey(string $key): string
261
    {
262
        $prefix = $this->getPrefix();
263
264
        if (false === strpos($key, $prefix)) {
265
            $key = $prefix . $key;
266
        }
267 1
268
        return $key;
269 1
    }
270
271 1
    //region File lock related operations
272 1
273
    /**
274
     * Acquire a lock on the given handle
275 1
     *
276
     * @param resource $handle
277
     * @param int $lock The lock operation (`LOCK_SH`, `LOCK_EX`, or `LOCK_UN`)
278
     * @param int $attempts The number of attempts before returning false
279
     * @return bool True if the lock operation was successful
280
     *
281
     * @see LOCK_SH
282
     * @see LOCK_EX
283
     * @see LOCK_UN
284
     */
285
    protected function acquireLock($handle, int $lock, int $attempts = 5): bool
286
    {
287
        for ($i = 0; $i < $attempts; $i++) {
288
            if (flock($handle, $lock)) {
289
                return true;
290
            }
291
292
            usleep(100);
293
        }
294
295
        return false;
296
    }
297
298
    /**
299
     * Get contents of a file with shared access.
300
     *
301
     * Example of the callable:
302
     * ```php
303
     * $property = function($handler, array $data) {
304
     *      return $data['someProperty'];
305
     * }
306
     *
307
     * // $property now has the contents of $data['someProperty']
308
     * ```
309
     *
310
     * @param string $path
311
     * @param bool $exclusive Whether we should use an exclusive or shared lock. If you are writing to the file, an
312
     *  exclusive lock is recommended
313
     * @param callable|null $callback The callable to call when we have locked the file. The first argument is the handle,
314
     *  the second is an array with the cache contents. If left empty, a default callable will be used that returns the
315
     *  data of the file
316
     * @return mixed The output of the callable
317
     */
318
    public function lockedGet(string $path, bool $exclusive = false, ?callable $callback = null)
319
    {
320
        if ($callback === null) {
321
            $callback = function ($handler, $data) {
322
                return $data;
323
            };
324
        }
325
326
        if ( ! file_exists($path)) {
327
            $this->createCacheFile();
328
        }
329
330
        $handle = @fopen($path, 'r+b');
331
        $lock   = $exclusive ? LOCK_EX : LOCK_SH;
332
333
        if ($handle === false) {
334
            return null;
335
        }
336
337
        try {
338
            if ( ! $this->acquireLock($handle, $lock)) {
339
                return null;
340
            }
341
342
            clearstatcache(true, $path);
343
344
            $contents = fread($handle, filesize($path) ?: 1);
345
346
            // Read the JSON data
347
            $data = @json_decode($contents, true) ?? [];
348
349
            return $callback($handle, $data);
350
        } finally {
351
            $this->acquireLock($handle, LOCK_UN);
352
            fclose($handle);
353
        }
354
    }
355
356
    /**
357
     * Write contents to the given path while locking the file
358
     *
359
     * This locks the file during the callback: no other instances can read/modify the file while this operation is
360
     * taking place. As such, one should use the data provided as an argument in the callback for modifying the file
361
     * to prevent the loss of data.
362
     *
363
     * Example of the callable:
364
     * ```php
365
     * function(array $data) {
366
     *     $data['someProperty'] = true;
367
     *
368
     *     return $data;
369
     * }
370
     * ```
371
     *
372
     * @param string $path The path of the file to write to
373
     * @param callable $callback A callable for transforming the data. The first argument of the callable is the current
374
     *  file contents, the return value will be the new contents (which will be json encoded if it is not a string
375
     *  already)
376
     * @return int|bool The amount of bytes that were written, or false if the write failed
377
     */
378
    public function lockedSet(string $path, callable $callback)
379
    {
380
        return $this->lockedGet($path, true, function ($handle, array $data) use ($callback) {
381
            $data = $callback($data) ?? [];
382
383
            ftruncate($handle, 0);
384
            rewind($handle);
385
386
            $data = \is_string($data) ? $data : json_encode($data);
387
            $write = fwrite($handle, $data);
388
389
            fflush($handle);
390
391
            return $write;
392
        });
393
    }
394
    //endregion
395
}
396