Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.30% covered (success)
96.30%
52 / 54
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Redis
96.30% covered (success)
96.30%
52 / 54
92.86% covered (success)
92.86%
13 / 14
31
0.00% covered (danger)
0.00%
0 / 1
 __construct
71.43% covered (success)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 redis
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getItemTtl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 saveItem
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getItem
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 hasItem
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteItem
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decrementItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 evalIncrement
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 fetchVersion
100.00% covered (success)
100.00%
1 / 1
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 * Redis cache adapter 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 Redis extends AbstractAdapter
30{
31
32    /**
33     * Traits
34     */
35    use NamespacedVersionedKeys;
36
37    /**
38     * Redis object
39     * @var ?\Redis
40     */
41    protected ?\Redis $redis = null;
42
43    /**
44     * Cache namespace
45     * @var string
46     */
47    protected string $namespace = 'pop_cache';
48
49    /**
50     * Constructor
51     *
52     * Instantiate the memcache cache object
53     *
54     * @param  int    $ttl
55     * @param  string $host
56     * @param  int    $port
57     * @param  string $namespace
58     * @param  Clock\ClockInterface $clock
59     * @throws Exception
60     */
61    public function __construct(
62        int $ttl = 0, string $host = 'localhost', int $port = 6379, string $namespace = 'pop_cache',
63        Clock\ClockInterface $clock = new Clock\SystemClock()
64    )
65    {
66        parent::__construct($ttl, $clock);
67        if (!class_exists('Redis', false)) {
68            throw new Exception('Error: Redis is not available.');
69        }
70
71        $this->namespace = $namespace;
72        $this->redis     = new \Redis();
73        if (!$this->redis->connect($host, (int)$port)) {
74            throw new Exception('Error: Unable to connect to the redis server.');
75        }
76    }
77
78    /**
79     * Get the redis object.
80     *
81     * @return \Redis
82     */
83    public function redis(): \Redis
84    {
85        return $this->redis;
86    }
87
88    /**
89     * Get the current version of redis.
90     *
91     * @return string
92     */
93    public function getVersion(): string
94    {
95        return $this->redis->info()['redis_version'];
96    }
97
98    /**
99     * Get the time-to-live for an item in cache
100     *
101     * @param  string $id
102     * @param  int    $default
103     * @return int
104     */
105    public function getItemTtl(string $id, int $default = 0): int
106    {
107        $cacheValue = $this->redis->get($this->key($id));
108        $ttl        = $default;
109
110        if (is_string($cacheValue) && str_starts_with($cacheValue, 'a:')) {
111            $cacheValue = unserialize($cacheValue, ['allowed_classes' => false]);
112            if (is_array($cacheValue) && array_key_exists('ttl', $cacheValue)) {
113                $ttl = $cacheValue['ttl'];
114            }
115        }
116
117        return $ttl;
118    }
119
120    /**
121     * Save an item to cache
122     *
123     * @param  string $id
124     * @param  mixed  $value
125     * @param  ?int   $ttl
126     * @return Redis
127     */
128    public function saveItem(string $id, mixed $value, ?int $ttl = null): Redis
129    {
130        $cacheValue = [
131            'start' => $this->clock->now(),
132            'ttl'   => ($ttl !== null) ? $ttl : $this->ttl,
133            'value' => $value
134        ];
135
136        if ($cacheValue['ttl'] != 0) {
137            $this->redis->set($this->key($id), serialize($cacheValue), $cacheValue['ttl']);
138        } else {
139            $this->redis->set($this->key($id), serialize($cacheValue));
140        }
141        return $this;
142    }
143
144    /**
145     * Get an item from cache
146     *
147     * @param  string $id
148     * @param  mixed  $default
149     * @return mixed
150     */
151    public function getItem(string $id, mixed $default = false): mixed
152    {
153        $cacheValue = $this->redis->get($this->key($id));
154        $value      = $default;
155
156        if (is_string($cacheValue) && str_starts_with($cacheValue, 'a:')) {
157            $cacheValue = unserialize($cacheValue, ['allowed_classes' => false]);
158            if (is_array($cacheValue) && array_key_exists('start', $cacheValue) &&
159                array_key_exists('ttl', $cacheValue) && array_key_exists('value', $cacheValue)) {
160                if ($this->isFresh($cacheValue)) {
161                    $value = $cacheValue['value'];
162                } else {
163                    $this->deleteItem($id);
164                }
165            }
166        }
167
168        return $value;
169    }
170
171    /**
172     * Determine if the item exist in cache
173     *
174     * @param  string $id
175     * @return bool
176     */
177    public function hasItem(string $id): bool
178    {
179        $cacheValue = $this->getItem($id);
180        return ($cacheValue !== false);
181    }
182
183    /**
184     * Delete a value in cache
185     *
186     * @param  string $id
187     * @return Redis
188     */
189    public function deleteItem(string $id): Redis
190    {
191        $this->redis->del($this->key($id));
192        return $this;
193    }
194
195    /**
196     * Clear all stored values from cache
197     *
198     * @return Redis
199     */
200    public function clear(): Redis
201    {
202        $this->redis->set($this->versionKey(), $this->resolveVersion() + 1);
203        return $this;
204    }
205
206    /**
207     * Destroy cache resource
208     *
209     * @return Redis
210     */
211    public function destroy(): Redis
212    {
213        $this->clear();
214        $this->redis = null;
215        return $this;
216    }
217
218    /**
219     * Lua script for incrementItem()/decrementItem(): atomically seeds a new counter at the given initial
220     * value (with a TTL, if any) when the key doesn't exist yet, then applies the delta. Redis's
221     * single-threaded script execution guarantees the whole sequence is atomic. decrementItem() reuses this
222     * same script by passing a negative amount — INCRBY with a negative delta is exactly DECRBY.
223     * @var string
224     */
225    protected const string INCREMENT_SCRIPT = <<<'LUA'
226        local key = KEYS[1]
227        local amount = tonumber(ARGV[1])
228        local initial = tonumber(ARGV[2])
229        local ttl = tonumber(ARGV[3])
230        if redis.call('EXISTS', key) == 0 then
231            if ttl > 0 then
232                redis.call('SET', key, initial, 'EX', ttl)
233            else
234                redis.call('SET', key, initial)
235            end
236        end
237        return redis.call('INCRBY', key, amount)
238        LUA;
239
240    /**
241     * Atomically increment a counter in cache, creating it at $initial if it doesn't exist
242     *
243     * Stored as a raw scalar via a Lua script (see INCREMENT_SCRIPT), bypassing the start/ttl/value
244     * envelope used by saveItem()/getItem() entirely — a counter key and a saveItem()-managed key are two
245     * incompatible storage formats on this adapter, and a counter is not readable via getItem(). $ttl is
246     * honored only when the counter is first created; a later call does not refresh an existing counter's
247     * expiry.
248     *
249     * @param  string $id
250     * @param  int    $amount
251     * @param  int    $initial
252     * @param  ?int   $ttl
253     * @throws Exception
254     * @return int
255     */
256    public function incrementItem(string $id, int $amount = 1, int $initial = 0, ?int $ttl = null): int
257    {
258        return $this->evalIncrement($id, $amount, $initial, $ttl);
259    }
260
261    /**
262     * Atomically decrement a counter in cache, creating it at $initial if it doesn't exist
263     *
264     * Stored as a raw scalar via a Lua script (see INCREMENT_SCRIPT), bypassing the start/ttl/value
265     * envelope used by saveItem()/getItem() entirely — a counter key and a saveItem()-managed key are two
266     * incompatible storage formats on this adapter, and a counter is not readable via getItem(). Unlike
267     * Memcached::decrement(), Redis allows the result to go negative (no clamping).
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        return $this->evalIncrement($id, -$amount, $initial, $ttl);
279    }
280
281    /**
282     * Shared implementation for incrementItem()/decrementItem(), executing INCREMENT_SCRIPT atomically
283     *
284     * @param  string $id
285     * @param  int    $amount
286     * @param  int    $initial
287     * @param  ?int   $ttl
288     * @throws Exception
289     * @return int
290     */
291    protected function evalIncrement(string $id, int $amount, int $initial, ?int $ttl): int
292    {
293        $key = $this->key($id);
294        $ttl = ($ttl !== null) ? $ttl : $this->ttl;
295
296        $this->redis->clearLastError();
297        $result = $this->redis->eval(self::INCREMENT_SCRIPT, [$key, $amount, $initial, $ttl], 1);
298
299        if ($result === false) {
300            throw new Exception('Error: The value at that key is not numeric.');
301        }
302
303        return $result;
304    }
305
306    /**
307     * Fetch the raw version value from redis, or false if it isn't set
308     *
309     * @param  string $key
310     * @return mixed
311     */
312    protected function fetchVersion(string $key): mixed
313    {
314        return $this->redis->get($key);
315    }
316
317}