Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
File
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
14 / 14
39
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDir
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getItemTtl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 saveItem
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getItem
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 hasItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 deleteItem
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clear
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 destroy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 incrementItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 decrementItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 clearShard
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 fileId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Cache\Adapter;
16
17use Pop\Cache\Clock;
18
19/**
20 * File adapter cache class
21 *
22 * @category   Pop
23 * @package    Pop\Cache
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29class File extends AbstractAdapter
30{
31
32    /**
33     * Cache dir
34     * @var ?string
35     */
36    protected ?string $dir = null;
37
38    /**
39     * Constructor
40     *
41     * Instantiate the cache file object
42     *
43     * @param  string $dir
44     * @param  int    $ttl
45     * @param  Clock\ClockInterface $clock
46     */
47    public function __construct(string $dir, int $ttl = 0, Clock\ClockInterface $clock = new Clock\SystemClock())
48    {
49        parent::__construct($ttl, $clock);
50        $this->setDir($dir);
51    }
52
53    /**
54     * Set the current cache dir
55     *
56     * @param  string $dir
57     * @throws Exception
58     * @return File
59     */
60    public function setDir(string $dir): File
61    {
62        if (!file_exists($dir)) {
63            throw new Exception('Error: That cache directory does not exist.');
64        } else if (!is_writable($dir)) {
65            throw new Exception('Error: That cache directory is not writable.');
66        }
67
68        $this->dir = realpath($dir);
69
70        return $this;
71    }
72
73    /**
74     * Get the current cache dir
75     *
76     * @return ?string
77     */
78    public function getDir(): ?string
79    {
80        return $this->dir;
81    }
82
83    /**
84     * Get the time-to-live for an item in cache
85     *
86     * @param  string $id
87     * @param  int    $default
88     * @return int
89     */
90    public function getItemTtl(string $id, int $default = 0): int
91    {
92        $fileId = $this->fileId($id);
93        $ttl    = $default;
94
95        if (file_exists($fileId)) {
96            $cacheValue = unserialize(file_get_contents($fileId), ['allowed_classes' => false]);
97            $ttl        = $cacheValue['ttl'];
98        }
99
100        return $ttl;
101    }
102
103    /**
104     * Save an item to cache
105     *
106     * @param  string $id
107     * @param  mixed  $value
108     * @param  ?int   $ttl
109     * @throws Exception
110     * @return File
111     */
112    public function saveItem(string $id, mixed $value, ?int $ttl = null): File
113    {
114        $fileId   = $this->fileId($id);
115        $shardDir = dirname($fileId);
116
117        if (!is_dir($shardDir) && !@mkdir($shardDir, 0777, true) && !is_dir($shardDir)) {
118            throw new Exception('Error: Unable to create the cache shard directory.');
119        }
120
121        $tmpFile = $shardDir . DIRECTORY_SEPARATOR . uniqid('.tmp-', true);
122
123        file_put_contents($tmpFile, serialize([
124            'start' => $this->clock->now(),
125            'ttl'   => ($ttl !== null) ? $ttl : $this->ttl,
126            'value' => $value
127        ]));
128        rename($tmpFile, $fileId);
129
130        return $this;
131    }
132
133    /**
134     * Get an item from cache
135     *
136     * @param  string $id
137     * @param  mixed  $default
138     * @return mixed
139     */
140    public function getItem(string $id, mixed $default = false): mixed
141    {
142        $fileId = $this->fileId($id);
143        $value  = $default;
144
145        if (file_exists($fileId)) {
146            $cacheValue = unserialize(file_get_contents($fileId), ['allowed_classes' => false]);
147            if ($this->isFresh($cacheValue)) {
148                $value = $cacheValue['value'];
149            } else {
150                $this->deleteItem($id);
151            }
152        }
153
154        return $value;
155    }
156
157    /**
158     * Determine if the item exist in cache
159     *
160     * @param  string $id
161     * @return bool
162     */
163    public function hasItem(string $id): bool
164    {
165        $fileId = $this->fileId($id);
166        $result = false;
167
168        if (file_exists($fileId)) {
169            $cacheValue = unserialize(file_get_contents($fileId), ['allowed_classes' => false]);
170            $result     = $this->isFresh($cacheValue);
171        }
172
173        return $result;
174    }
175
176    /**
177     * Delete a value in cache
178     *
179     * @param  string $id
180     * @return File
181     */
182    public function deleteItem(string $id): File
183    {
184        $fileId = $this->fileId($id);
185        if (file_exists($fileId)) {
186            unlink($fileId);
187        }
188        return $this;
189    }
190
191    /**
192     * Clear all stored values from cache
193     *
194     * @return File
195     */
196    public function clear(): File
197    {
198        if (!$dh = @opendir($this->dir)) {
199            return $this;
200        }
201
202        while (false !== ($obj = readdir($dh))) {
203            if (($obj == '.') || ($obj == '..')) {
204                continue;
205            }
206
207            $path = $this->dir . DIRECTORY_SEPARATOR . $obj;
208
209            if (is_dir($path) && preg_match('/^[0-9a-f]{2}$/', $obj)) {
210                $this->clearShard($path);
211            } else if (is_file($path)) {
212                unlink($path);
213            }
214        }
215
216        closedir($dh);
217
218        return $this;
219    }
220
221    /**
222     * Destroy cache resource
223     *
224     * @return File
225     */
226    public function destroy(): File
227    {
228        $this->clear();
229        @rmdir($this->dir);
230
231        return $this;
232    }
233
234    /**
235     * Atomically increment a counter in cache, creating it at $initial if it doesn't exist
236     *
237     * Non-atomic read-modify-write through the same start/ttl/value envelope used by saveItem()/getItem() —
238     * File has no native atomic primitive, so a counter here is an ordinary cached integer, fully
239     * interoperable with getItem()/hasItem()/deleteItem().
240     *
241     * @param  string $id
242     * @param  int    $amount
243     * @param  int    $initial
244     * @param  ?int   $ttl
245     * @throws Exception
246     * @return int
247     */
248    public function incrementItem(string $id, int $amount = 1, int $initial = 0, ?int $ttl = null): int
249    {
250        $current = $this->getItem($id, $initial);
251
252        if (!is_int($current)) {
253            throw new Exception('Error: The value at that key is not numeric.');
254        }
255
256        $value = $current + $amount;
257        $this->saveItem($id, $value, $ttl);
258
259        return $value;
260    }
261
262    /**
263     * Atomically decrement a counter in cache, creating it at $initial if it doesn't exist
264     *
265     * Non-atomic read-modify-write through the same start/ttl/value envelope used by saveItem()/getItem() —
266     * File has no native atomic primitive, so a counter here is an ordinary cached integer, fully
267     * interoperable with getItem()/hasItem()/deleteItem().
268     *
269     * @param  string $id
270     * @param  int    $amount
271     * @param  int    $initial
272     * @param  ?int   $ttl
273     * @throws Exception
274     * @return int
275     */
276    public function decrementItem(string $id, int $amount = 1, int $initial = 0, ?int $ttl = null): int
277    {
278        $current = $this->getItem($id, $initial);
279
280        if (!is_int($current)) {
281            throw new Exception('Error: The value at that key is not numeric.');
282        }
283
284        $value = $current - $amount;
285        $this->saveItem($id, $value, $ttl);
286
287        return $value;
288    }
289
290    /**
291     * Clear all files in a shard directory and remove the now-empty directory
292     *
293     * @param  string $shardDir
294     * @return void
295     */
296    protected function clearShard(string $shardDir): void
297    {
298        if (!$dh = @opendir($shardDir)) {
299            return;
300        }
301
302        while (false !== ($obj = readdir($dh))) {
303            if (($obj != '.') && ($obj != '..') && is_file($shardDir . DIRECTORY_SEPARATOR . $obj)) {
304                unlink($shardDir . DIRECTORY_SEPARATOR . $obj);
305            }
306        }
307
308        closedir($dh);
309        @rmdir($shardDir);
310    }
311
312    /**
313     * Build the sharded storage path for an item id
314     *
315     * @param  string $id
316     * @return string
317     */
318    protected function fileId(string $id): string
319    {
320        $hash = sha1($id);
321        return $this->dir . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . $hash;
322    }
323
324}