Passed
Pull Request — master (#332)
by
unknown
13:01 queued 03:56
created

FileStore::lockedSet()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 8
c 0
b 0
f 0
nc 1
nop 2
dl 0
loc 14
ccs 8
cts 8
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 4
    public function __construct(string $cacheDir = null, string $cacheFile = null)
23
    {
24 4
        $cacheDir  = $cacheDir ?? Config::get('file.dir');
25 4
        $cacheFile = $cacheFile ?? Config::get('file.name');
26
27 4
        $this->setCacheDir($cacheDir);
28 4
        $this->setCacheFile($cacheFile);
29 4
    }
30
31
    /**
32
     * Set cache dir.
33
     *
34
     * @param string $path
35
     *
36
     * @return self
37
     */
38 3
    public function setCacheDir(string $path): self
39
    {
40 3
        $this->cacheDir = $path;
41
42 3
        return $this;
43
    }
44
45
    /**
46
     * Get cache dir.
47
     *
48
     * @return string
49
     */
50 1
    public function getCacheDir(): string
51
    {
52 1
        return $this->cacheDir;
53
    }
54
55
    /**
56
     * Set cache file.
57
     *
58
     * @param string $file
59
     *
60
     * @return self
61
     */
62 3
    public function setCacheFile(string $file): self
63
    {
64 3
        $this->cacheFile = $file;
65
66 3
        return $this;
67
    }
68
69
    /**
70
     * Get cache file.
71
     *
72
     * @return string
73
     */
74 6
    public function getCacheFile(): string
75
    {
76 6
        return $this->cacheDir . $this->cacheFile;
77
    }
78
79
    /**
80
     * Create cache dir if not exists.
81
     *
82
     * @return void
83
     */
84 3
    protected function createCacheDir()
85
    {
86 3
        if ( ! file_exists($this->cacheDir)) {
87 3
            mkdir($this->cacheDir);
88
        }
89 3
    }
90
91
    /**
92
     * Create a cache file.
93
     *
94
     * @return void
95
     */
96 3
    protected function createCacheFile()
97
    {
98 3
        $this->createCacheDir();
99
100 3
        $cacheFilePath = $this->getCacheFile();
101
102 3
        if ( ! file_exists($cacheFilePath)) {
103 3
            touch($cacheFilePath);
104
        }
105 3
    }
106
107
    /**
108
     * {@inheritDoc}
109
     */
110 8
    public function get(string $key, bool $withExpired = false)
111
    {
112 8
        $key = $this->getActualCacheKey($key);
113
114 8
        return $this->lockedGet(
115 8
            $this->getCacheFile(),
116 8
            false,
117
            function ($handler, array $content) use ($withExpired, $key) {
118 8
                if (empty($content[$key])) {
119 3
                    return null;
120
                }
121
122 5
                $meta = $content[$key];
123
124 5
                if ( ! $withExpired && ! $this->isValid($meta)) {
125 2
                    return null;
126
                }
127
128 4
                return $meta;
129 8
            }
130
        );
131
    }
132
133
    /**
134
     * Write the contents of a file with exclusive lock.
135
     *
136
     * It is not recommended to use this method, for updating files as it will nullify any changes that have been made
137
     * to the file between retrieving $contents and writing the changes. As such, one should instead use lockedSet.
138
     *
139
     * @param string $path
140
     * @param string $contents
141
     *
142
     * @return int|bool
143
     * @see LockedFileStore::lockedSet
144
     *
145
     * @deprecated It is not recommended to use this method, use `lockedSet` instead.
146
     *
147
148
     */
149
    public function put(string $path, string $contents): int
150
    {
151
        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
            $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
            }
156
        );
157
    }
158
159
    /**
160
     * {@inheritDoc}
161
     */
162 8
    public function set(string $key, $value)
163
    {
164 8
        $cacheKey  = $this->getActualCacheKey($key);
165 8
        $cacheFile = $this->getCacheFile();
166
167 8
        if ( ! file_exists($cacheFile)) {
168 8
            $this->createCacheFile();
169
        }
170
171
        $this->lockedSet($cacheFile, function (array $data) use ($value, $cacheKey) {
172 8
            if ( ! empty($data[$cacheKey]) && \is_array($value)) {
173 3
                $data[$cacheKey] = $value + $data[$cacheKey];
174
            } else {
175 8
                $data[$cacheKey] = $value;
176
            }
177
178 8
            return $data;
179 8
        });
180 8
    }
181
182
    /**
183
     * {@inheritDoc}
184
     */
185 1
    public function delete(string $key): bool
186
    {
187 1
        $cacheKey = $this->getActualCacheKey($key);
188 1
        $deletion = false;
189
190 1
        return $this->lockedSet(
191 1
            $this->getCacheFile(),
192
            function ($data) use ($cacheKey, &$deletion) {
193 1
                if (isset($data[$cacheKey])) {
194 1
                    unset($data[$cacheKey]);
195 1
                    $deletion = true;
196
                }
197
198 1
                return $data;
199 1
            }
200 1
        ) !== false && $deletion;
201
    }
202
203
    /**
204
     * {@inheritDoc}
205
     */
206 2
    public function keys() : array
207
    {
208 2
        $contents = $this->getCacheContents();
209
210 2
        if (\is_array($contents)) {
211 1
            return array_keys($contents);
212
        }
213
214 1
        return [];
215
    }
216
217
    /**
218
     * Check if cache is still valid.
219
     *
220
     * @param string|array $meta The cache key, or the metadata object
221
     * @return bool
222
     */
223 3
    public function isValid($meta): bool
224
    {
225 3
        if ( ! \is_array($meta)) {
226 2
            $key  = $this->getActualCacheKey($meta);
227 2
            $meta = $this->lockedGet($this->getCacheFile())[$key] ?? [];
228
        }
229
230 3
        if (empty($meta['expires_at'])) {
231 1
            return false;
232
        }
233
234 2
        return Carbon::now() < Carbon::createFromFormat(self::RFC_7231, $meta['expires_at']);
235
    }
236
237
    /**
238
     * Get cache contents.
239
     *
240
     * @return array|bool
241
     */
242 3
    public function getCacheContents()
243
    {
244 3
        $cacheFile = $this->getCacheFile();
245
246 3
        if ( ! file_exists($cacheFile)) {
247 1
            return false;
248
        }
249
250 2
        return $this->lockedGet($cacheFile);
251
    }
252
253
    /**
254
     * Get actual cache key with prefix.
255
     *
256
     * @param string $key
257
     *
258
     * @return string
259
     */
260 2
    public function getActualCacheKey(string $key): string
261
    {
262 2
        $prefix = $this->getPrefix();
263
264 2
        if (false === strpos($key, $prefix)) {
265 2
            $key = $prefix . $key;
266
        }
267
268 2
        return $key;
269
    }
270
271
    //region File lock related operations
272
273
    /**
274
     * Acquire a lock on the given handle
275
     *
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 1
    protected function acquireLock($handle, int $lock, int $attempts = 5): bool
286
    {
287 1
        for ($i = 0; $i < $attempts; $i++) {
288 1
            if (flock($handle, $lock)) {
289 1
                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 2
    public function lockedGet(string $path, bool $exclusive = false, ?callable $callback = null)
319
    {
320 2
        if ($callback === null) {
321
            $callback = function ($handler, $data) {
322 1
                return $data;
323 2
            };
324
        }
325
326 2
        if ( ! file_exists($path)) {
327 1
            $this->createCacheFile();
328
        }
329
330 2
        $handle = @fopen($path, 'r+b');
331 2
        $lock   = $exclusive ? LOCK_EX : LOCK_SH;
332
333 2
        if ($handle === false) {
334 1
            return null;
335
        }
336
337
        try {
338 1
            if ( ! $this->acquireLock($handle, $lock)) {
339
                return null;
340
            }
341
342 1
            clearstatcache(true, $path);
343
344 1
            $contents = fread($handle, filesize($path) ?: 1);
345
346
            // Read the JSON data
347 1
            $data = @json_decode($contents, true) ?? [];
348
349 1
            return $callback($handle, $data);
350
        } finally {
351 1
            $this->acquireLock($handle, LOCK_UN);
352 1
            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 1
    public function lockedSet(string $path, callable $callback)
379
    {
380
        return $this->lockedGet($path, true, function ($handle, array $data) use ($callback) {
381 1
            $data = $callback($data) ?? [];
382
383 1
            ftruncate($handle, 0);
384 1
            rewind($handle);
385
386 1
            $data = \is_string($data) ? $data : json_encode($data);
387 1
            $write = fwrite($handle, $data);
388
389 1
            fflush($handle);
390
391 1
            return $write;
392 1
        });
393
    }
394
    //endregion
395
}
396