Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.94% covered (warning)
66.94%
81 / 121
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedisConfigurationStore
66.94% covered (warning)
66.94%
81 / 121
36.36% covered (danger)
36.36%
4 / 11
169.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateConfigValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchAll
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 insert
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 delete
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 renameKey
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
 getInstalledRedisVersion
80.00% covered (success)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 warmFromRows
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 getRedisClient
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHashKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRedisClient
40.82% covered (danger)
40.82%
20 / 49
0.00% covered (danger)
0.00%
0 / 1
122.33
1<?php
2
3/**
4 * Redis-backed configuration storage
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-23
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Configuration\Storage;
21
22use Redis;
23use RedisException;
24use RuntimeException;
25
26class RedisConfigurationStore implements ConfigurationStoreInterface
27{
28    private ?Redis $redisClient = null;
29
30    public function __construct(
31        private readonly ConfigurationStorageSettings $settings,
32    ) {
33    }
34
35    public function updateConfigValue(string $key, string $value): bool
36    {
37        try {
38            $redis = $this->getRedisClient();
39            return false !== $redis->hSet($this->getHashKey(), $key, $value);
40        } catch (RedisException $e) {
41            throw new RuntimeException('Redis updateConfigValue failed: ' . $e->getMessage(), 0, $e);
42        }
43    }
44
45    /**
46     * @return array<int, \stdClass>
47     */
48    public function fetchAll(): array
49    {
50        try {
51            $redis = $this->getRedisClient();
52            $entries = $redis->hGetAll($this->getHashKey());
53            if (!is_array($entries) || $entries === []) {
54                return [];
55            }
56
57            $rows = [];
58            foreach ($entries as $name => $value) {
59                $rows[] = (object) [
60                    'config_name' => (string) $name,
61                    'config_value' => (string) $value,
62                ];
63            }
64
65            return $rows;
66        } catch (RedisException $e) {
67            throw new RuntimeException('Redis fetchAll failed: ' . $e->getMessage(), 0, $e);
68        }
69    }
70
71    public function insert(string $name, string $value): bool
72    {
73        try {
74            $redis = $this->getRedisClient();
75            return (bool) $redis->hSetNx($this->getHashKey(), $name, $value);
76        } catch (RedisException $e) {
77            throw new RuntimeException('Redis insert failed: ' . $e->getMessage(), 0, $e);
78        }
79    }
80
81    public function delete(string $name): bool
82    {
83        try {
84            $redis = $this->getRedisClient();
85            $result = $redis->hDel($this->getHashKey(), $name);
86            return $result !== false && $result > 0;
87        } catch (RedisException $e) {
88            throw new RuntimeException('Redis delete failed: ' . $e->getMessage(), 0, $e);
89        }
90    }
91
92    public function renameKey(string $currentKey, string $newKey): bool
93    {
94        try {
95            $redis = $this->getRedisClient();
96            $oldValue = $redis->hGet($this->getHashKey(), $currentKey);
97            if ($oldValue === false) {
98                return false;
99            }
100
101            $setResult = $redis->hSet($this->getHashKey(), $newKey, (string) $oldValue);
102            if ($setResult === false) {
103                return false;
104            }
105
106            if ((int) $redis->hDel($this->getHashKey(), $currentKey) < 1) {
107                $redis->hDel($this->getHashKey(), $newKey);
108                return false;
109            }
110
111            return true;
112        } catch (RedisException $e) {
113            throw new RuntimeException('Redis renameKey failed: ' . $e->getMessage(), 0, $e);
114        }
115    }
116
117    public function getInstalledRedisVersion(): string
118    {
119        try {
120            $redis = $this->getRedisClient();
121            $serverInfo = $redis->info('server');
122
123            $serverVersion = 'unknown';
124            if (is_array($serverInfo) && array_key_exists('redis_version', $serverInfo)) {
125                $serverVersion = (string) $serverInfo['redis_version'];
126            }
127            $configuredExtensionVersion = phpversion('redis');
128            $extensionVersion = $configuredExtensionVersion !== false ? $configuredExtensionVersion : 'unknown';
129
130            return sprintf('%s (ext-redis %s)', $serverVersion, $extensionVersion);
131        } catch (RedisException $e) {
132            throw new RuntimeException('Redis getInstalledRedisVersion failed: ' . $e->getMessage(), 0, $e);
133        }
134    }
135
136    /**
137     * @param array<int, \stdClass> $rows
138     */
139    public function warmFromRows(array $rows): bool
140    {
141        if ($rows === []) {
142            return true;
143        }
144
145        try {
146            $redis = $this->getRedisClient();
147            $keyValueMap = [];
148            foreach ($rows as $row) {
149                if (!property_exists($row, 'config_name') || $row->config_name === null) {
150                    continue;
151                }
152
153                $keyValueMap[(string) $row->config_name] = (string) ($row->config_value ?? '');
154            }
155
156            if ($keyValueMap === []) {
157                return true;
158            }
159
160            $hashKey = $this->getHashKey();
161            $redis->multi();
162            $redis->del($hashKey);
163            $redis->hMSet($hashKey, $keyValueMap);
164            $results = $redis->exec();
165
166            return is_array($results) && ($results[1] ?? null) !== false;
167        } catch (RedisException $e) {
168            throw new RuntimeException('Redis warmFromRows failed: ' . $e->getMessage(), 0, $e);
169        }
170    }
171
172    private function getRedisClient(): Redis
173    {
174        if ($this->redisClient === null) {
175            $this->redisClient = $this->createRedisClient();
176        }
177
178        return $this->redisClient;
179    }
180
181    private function getHashKey(): string
182    {
183        return $this->settings->redisPrefix . 'items';
184    }
185
186    private function createRedisClient(): Redis
187    {
188        if (!extension_loaded('redis')) {
189            throw new RuntimeException('Redis configuration storage requires the PHP redis extension (ext-redis).');
190        }
191
192        $parsedUrl = parse_url($this->settings->redisDsn);
193        if ($parsedUrl === false || !array_key_exists('scheme', $parsedUrl)) {
194            throw new RuntimeException('Invalid Redis DSN for configuration storage.');
195        }
196
197        try {
198            $redis = new Redis();
199            $scheme = strtolower($parsedUrl['scheme']);
200            $timeout = $this->settings->connectTimeout;
201
202            if ($scheme === 'redis' || $scheme === 'tcp') {
203                $host = $parsedUrl['host'] ?? '127.0.0.1';
204                $port = (int) ($parsedUrl['port'] ?? 6379);
205                $connected = $redis->connect($host, $port, $timeout);
206                if ($connected !== true) {
207                    throw new RuntimeException(sprintf('Unable to connect to Redis at %s:%d', $host, $port));
208                }
209            }
210
211            if ($scheme === 'unix') {
212                $path = $parsedUrl['path'] ?? '';
213                if ($path === '') {
214                    throw new RuntimeException('Invalid Redis unix socket DSN for configuration storage.');
215                }
216
217                $connected = $redis->connect($path, 0, $timeout);
218                if ($connected !== true) {
219                    throw new RuntimeException(sprintf('Unable to connect to Redis unix socket at %s', $path));
220                }
221            }
222
223            if ($scheme !== 'redis' && $scheme !== 'tcp' && $scheme !== 'unix') {
224                throw new RuntimeException(sprintf(
225                    'Unsupported Redis DSN scheme "%s" for configuration storage.',
226                    $scheme,
227                ));
228            }
229
230            if (array_key_exists('pass', $parsedUrl) && $parsedUrl['pass'] !== '') {
231                $pass = urldecode($parsedUrl['pass']);
232                $authResult = $redis->auth($pass);
233                if (array_key_exists('user', $parsedUrl) && $parsedUrl['user'] !== '') {
234                    $user = urldecode($parsedUrl['user']);
235                    $authResult = $redis->auth([$user, $pass]);
236                }
237
238                if ($authResult !== true) {
239                    throw new RuntimeException('Redis authentication failed for configuration storage.');
240                }
241            }
242
243            $database = 0;
244            if (array_key_exists('query', $parsedUrl)) {
245                $queryParams = [];
246                parse_str($parsedUrl['query'], $queryParams);
247                $database = (int) ($queryParams['database'] ?? $queryParams['db'] ?? 0);
248            }
249
250            if ($redis->select($database) !== true) {
251                throw new RuntimeException(sprintf(
252                    'Failed to select Redis database %d for configuration storage.',
253                    $database,
254                ));
255            }
256
257            $readTimeout = $timeout > 0 ? $timeout : (float) ini_get('default_socket_timeout');
258            $redis->setOption(Redis::OPT_READ_TIMEOUT, $readTimeout);
259
260            return $redis;
261        } catch (RedisException $e) {
262            throw new RuntimeException('Redis connection failed: ' . $e->getMessage(), 0, $e);
263        }
264    }
265}