Passed
Pull Request — master (#332)
by
unknown
08:19
created

FileStore::lockedGet()   B

Complexity

Conditions 7
Paths 64

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7.0084

Importance

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