Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.00% covered (warning)
60.00%
48 / 80
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
HybridConfigurationStore
60.00% covered (warning)
60.00%
48 / 80
28.57% covered (danger)
28.57%
2 / 7
73.66
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
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 fetchAll
86.36% covered (success)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
8.16
 insert
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
6.50
 delete
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
6.50
 renameKey
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 resolveRedisStore
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Hybrid configuration storage (database + optional Redis cache)
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 Psr\Log\LoggerInterface;
23use RuntimeException;
24
25readonly class HybridConfigurationStore implements ConfigurationStoreInterface
26{
27    public function __construct(
28        private DatabaseConfigurationStore $databaseConfigurationStore,
29        private ConfigurationStorageSettingsResolver $settingsResolver,
30        private LoggerInterface $logger,
31        private ?FilesystemConfigurationCache $filesystemConfigurationCache = null,
32    ) {
33    }
34
35    public function updateConfigValue(string $key, string $value): bool
36    {
37        $result = $this->databaseConfigurationStore->updateConfigValue($key, $value);
38        if (!$result) {
39            return false;
40        }
41
42        $redisStore = $this->resolveRedisStore();
43        if ($redisStore !== null) {
44            try {
45                $redisStore->updateConfigValue($key, $value);
46            } catch (RuntimeException $exception) {
47                $this->logger->warning('Failed to update configuration key in Redis storage.', [
48                    'key' => $key,
49                    'error' => $exception->getMessage(),
50                ]);
51            }
52        }
53
54        $this->filesystemConfigurationCache?->clear();
55
56        return true;
57    }
58
59    /**
60     * @return array<int, \stdClass>
61     */
62    public function fetchAll(): array
63    {
64        $cachedRows = $this->filesystemConfigurationCache?->read();
65        if ($cachedRows !== null) {
66            return $cachedRows;
67        }
68
69        $redisStore = $this->resolveRedisStore();
70        if ($redisStore !== null) {
71            try {
72                $rows = $redisStore->fetchAll();
73                if ($rows !== []) {
74                    $this->filesystemConfigurationCache?->warm($rows);
75                    return $rows;
76                }
77            } catch (RuntimeException $exception) {
78                $this->logger->warning('Failed to fetch configuration from Redis storage.', [
79                    'error' => $exception->getMessage(),
80                ]);
81            }
82        }
83
84        $rows = $this->databaseConfigurationStore->fetchAll();
85
86        if ($redisStore !== null && $rows !== []) {
87            try {
88                $redisStore->warmFromRows($rows);
89            } catch (RuntimeException $exception) {
90                $this->logger->warning('Failed to warm Redis configuration storage from database.', [
91                    'error' => $exception->getMessage(),
92                ]);
93            }
94        }
95
96        $this->filesystemConfigurationCache?->warm($rows);
97
98        return $rows;
99    }
100
101    public function insert(string $name, string $value): bool
102    {
103        $result = $this->databaseConfigurationStore->insert($name, $value);
104        if (!$result) {
105            return false;
106        }
107
108        $redisStore = $this->resolveRedisStore();
109        if ($redisStore !== null) {
110            try {
111                $redisStore->insert($name, $value);
112            } catch (RuntimeException $exception) {
113                $this->logger->warning('Failed to insert configuration key into Redis storage.', [
114                    'key' => $name,
115                    'error' => $exception->getMessage(),
116                ]);
117            }
118        }
119
120        $this->filesystemConfigurationCache?->clear();
121
122        return true;
123    }
124
125    public function delete(string $name): bool
126    {
127        $result = $this->databaseConfigurationStore->delete($name);
128        if (!$result) {
129            return false;
130        }
131
132        $redisStore = $this->resolveRedisStore();
133        if ($redisStore !== null) {
134            try {
135                $redisStore->delete($name);
136            } catch (RuntimeException $exception) {
137                $this->logger->warning('Failed to delete configuration key from Redis storage.', [
138                    'key' => $name,
139                    'error' => $exception->getMessage(),
140                ]);
141            }
142        }
143
144        $this->filesystemConfigurationCache?->clear();
145
146        return true;
147    }
148
149    public function renameKey(string $currentKey, string $newKey): bool
150    {
151        $result = $this->databaseConfigurationStore->renameKey($currentKey, $newKey);
152        if (!$result) {
153            return false;
154        }
155
156        $redisStore = $this->resolveRedisStore();
157        if ($redisStore !== null) {
158            try {
159                $redisStore->renameKey($currentKey, $newKey);
160            } catch (RuntimeException $exception) {
161                $this->logger->warning('Failed to rename configuration key in Redis storage.', [
162                    'currentKey' => $currentKey,
163                    'newKey' => $newKey,
164                    'error' => $exception->getMessage(),
165                ]);
166            }
167        }
168
169        $this->filesystemConfigurationCache?->clear();
170
171        return true;
172    }
173
174    private function resolveRedisStore(): ?RedisConfigurationStore
175    {
176        $settings = $this->settingsResolver->resolve();
177        if (!$settings->enabled) {
178            return null;
179        }
180
181        return new RedisConfigurationStore($settings);
182    }
183}