PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / PluginLogicOptionsResolver.php

PluginLogicOptionsResolver.php in 404 Solution 4.3.0, at includes/core/PluginLogicOptionsResolver.php

313 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/PluginLogicDefaults.php';
8 require_once __DIR__ . '/../settings/StorageOptionContracts.php';
9
10 /**
11 * Owns the plugin's persistent settings option (`abj404_settings`):
12 * read, normalize-for-read, merge defaults, version-upgrade if the
13 * DB_VERSION stamp lags ABJ404_VERSION, normalize suggestion template
14 * tokens, cache by db-check mode, and write-back through
15 * StorageOptionContracts. Hosts the getOptions/updateOptions pair
16 * extracted from PluginLogic. Composed by PluginLogic and exposed via
17 * `$pluginLogic->optionsResolver()`; the service container key
18 * `options_repository` remains as the internal plumbing handle used by
19 * auth-time callers (PluginAdminAccessPolicy) that cannot route through
20 * PluginLogic without creating a resolution cycle.
21 */
22 class ABJ_404_Solution_PluginLogicOptionsResolver {
23
24 /** @var array<string, mixed>|null */
25 private $rawCache = null;
26
27 /** @var array<string, mixed>|null */
28 private $resolvedSkipDbCheck = null;
29
30 /** @var array<string, mixed>|null */
31 private $resolvedWithDbCheck = null;
32
33 /** @var self|null */
34 private static $instance = null;
35
36 /** @return self */
37 public static function getInstance(): self {
38 if (self::$instance !== null) {
39 return self::$instance;
40 }
41 self::$instance = new self();
42 return self::$instance;
43 }
44
45 /** Reset cached instance and in-process caches (test seam). @return void */
46 public static function reset(): void {
47 if (self::$instance !== null) {
48 self::$instance->clearCache();
49 }
50 self::$instance = null;
51 }
52
53 /** Clear in-process option caches. @return void */
54 public function clearCache(): void {
55 $this->rawCache = null;
56 $this->resolvedSkipDbCheck = null;
57 $this->resolvedWithDbCheck = null;
58 }
59
60 /**
61 * Return only the delegated plugin-admin option needed by the
62 * authorization policy. This intentionally avoids the full getOptions()
63 * read path because capability checks can run while plugin_logic is being
64 * resolved; the full path performs suggestion-template normalization via
65 * PluginLogicSettingsUpdate and would create an auth-time service cycle.
66 *
67 * @return mixed String, array, or default value accepted by the policy normalizer.
68 */
69 public function getPluginAdminUsersOption() {
70 $optionResult = get_option('abj404_settings');
71 if (!is_array($optionResult)) {
72 return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users'];
73 }
74
75 $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead(
76 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
77 $optionResult
78 );
79
80 if (array_key_exists('plugin_admin_users', $normalizedOptions)) {
81 return $normalizedOptions['plugin_admin_users'];
82 }
83
84 return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users'];
85 }
86
87 /**
88 * Resolve the current plugin options. With $skip_db_check=true, the
89 * DB_VERSION pipeline is skipped (used during the version-upgrade
90 * sequence itself and from contexts that must not trigger upgrades).
91 *
92 * @param bool $skip_db_check
93 * @return array<string, mixed>
94 */
95 public function getOptions(bool $skip_db_check = false): array {
96 if (!$skip_db_check && is_array($this->resolvedWithDbCheck)) {
97 return $this->resolvedWithDbCheck;
98 }
99 if ($skip_db_check) {
100 if (is_array($this->resolvedSkipDbCheck)) {
101 return $this->resolvedSkipDbCheck;
102 }
103 if (is_array($this->resolvedWithDbCheck)) {
104 return $this->resolvedWithDbCheck;
105 }
106 }
107
108 $legacyOptions = $this->legacyPluginLogicOptionsOverride();
109 if (is_array($legacyOptions)) {
110 return array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $legacyOptions);
111 }
112
113 if ($this->rawCache === null) {
114 $optionResult = get_option('abj404_settings');
115 if (is_array($optionResult)) {
116 $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead(
117 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
118 $optionResult
119 );
120 $this->rawCache = $normalizedOptions;
121 if ($normalizedOptions !== $optionResult) {
122 $this->updateOptions($normalizedOptions);
123 }
124 } else {
125 $this->rawCache = null;
126 }
127 }
128 $options = $this->rawCache;
129
130 if (!is_array($options)) {
131 add_option('abj404_settings', '', '', false);
132 $options = array();
133 }
134
135 $defaults = ABJ_404_Solution_PluginLogicDefaults::defaults();
136 $missing = false;
137 foreach ($defaults as $key => $value) {
138 if (!isset($options[$key]) || $options[$key] === '') {
139 $options[$key] = $value;
140 $missing = true;
141 }
142 }
143
144 if ($missing) {
145 $this->updateOptions($options);
146 }
147
148 if ($skip_db_check == false) {
149 if (!array_key_exists('DB_VERSION', $options) || $options['DB_VERSION'] != ABJ404_VERSION) {
150 $versionUpgrade = abj_service('version_upgrade');
151 if (is_object($versionUpgrade) && method_exists($versionUpgrade, 'upgradeIfNeeded')) {
152 $options = $versionUpgrade->upgradeIfNeeded($options);
153 } else {
154 $this->warn('version_upgrade service unavailable while reading options; skipped upgrade check.');
155 }
156 }
157 }
158
159 $pluginLogic = abj_service('plugin_logic');
160 $pluginLogicClass = 'ABJ_404_Solution_PluginLogic';
161 $settingsUpdate = is_object($pluginLogic) && method_exists($pluginLogic, 'settingsUpdate')
162 && (!(class_exists($pluginLogicClass) && is_a($pluginLogic, $pluginLogicClass))
163 || get_class($pluginLogic) === $pluginLogicClass)
164 ? $pluginLogic->settingsUpdate()
165 : null;
166 if (is_object($settingsUpdate)
167 && method_exists($settingsUpdate, 'normalizeSuggestionTemplateOptions')
168 && $settingsUpdate->normalizeSuggestionTemplateOptions($options)) {
169 $this->updateOptions($options);
170 }
171
172 if ($skip_db_check) {
173 $this->resolvedSkipDbCheck = $options;
174 } else {
175 $this->resolvedWithDbCheck = $options;
176 }
177
178 return $options;
179 }
180
181 /**
182 * Persist plugin options. Merges defaults, runs the storage write
183 * contract, calls update_option, then invalidates the in-process cache.
184 *
185 * @param array<string, mixed> $options
186 * @return void
187 */
188 public function updateOptions(array $options): void {
189 $options = array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $options);
190 $options = ABJ_404_Solution_StorageOptionContracts::prepareForWrite(
191 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
192 $options
193 );
194 update_option('abj404_settings', $options);
195 $this->rawCache = $options;
196 $this->resolvedSkipDbCheck = null;
197 $this->resolvedWithDbCheck = null;
198 }
199
200 /**
201 * Legacy test seam: returns options seeded by a test before the real
202 * WordPress option pipeline (and its DB_VERSION upgrade) is consulted.
203 * Production callers never trigger either branch.
204 *
205 * Two seam shapes are honored, both anchored on PluginLogic::$instance:
206 * 1. Reflection seam (older tests). ABJ_404_Solution_PluginLogic::$options
207 * is set to an array via ReflectionProperty + PluginLogicOptionsResolver::reset().
208 * Used by SpellChecker*Test, CodeReviewIssuesTest, LoggingTest, etc.
209 * 2. Subclass-getOptions seam (post-b2ab795d tests). A test subclass that
210 * extends ABJ_404_Solution_PluginLogic overrides getOptions() and is
211 * installed as the singleton. Real PluginLogic has no getOptions()
212 * method (deleted with the options migration), so method_exists() is a
213 * reliable test-subclass discriminator.
214 *
215 * @return array<string, mixed>|null
216 */
217 private function legacyPluginLogicOptionsOverride() {
218 if (!class_exists('ABJ_404_Solution_PluginLogic')) {
219 return null;
220 }
221 $pluginLogic = $this->readPluginLogicInstance();
222 if (!is_object($pluginLogic)) {
223 return null;
224 }
225
226 // Only the real PluginLogic class declares the private $options property; anonymous
227 // stubs (e.g. ShouldUpdatePluginTest::makeUpgrades) install a sibling class and would
228 // raise on the reflection. Guard so the subclass-getOptions branch below is reached.
229 if (is_a($pluginLogic, 'ABJ_404_Solution_PluginLogic')) {
230 $reflectedOptions = $this->readPluginLogicOptionsProperty($pluginLogic);
231 if (is_array($reflectedOptions)) {
232 return $reflectedOptions;
233 }
234 }
235
236 if (method_exists($pluginLogic, 'getOptions')) {
237 $maybeOptions = $this->callPluginLogicGetOptions($pluginLogic);
238 if (is_array($maybeOptions)) {
239 return $maybeOptions;
240 }
241 }
242
243 return null;
244 }
245
246 /** @return object|null PluginLogic singleton instance, or null when reflection fails. */
247 private function readPluginLogicInstance() {
248 try {
249 $instanceProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'instance');
250 $value = $instanceProperty->getValue();
251 return is_object($value) ? $value : null;
252 } catch (Throwable $e) {
253 $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$instance via reflection (' . $e->getMessage() . '); falling back to WordPress options.');
254 return null;
255 }
256 }
257
258 /**
259 * @param ABJ_404_Solution_PluginLogic $pluginLogic Real PluginLogic instance (caller verified).
260 * @return array<string, mixed>|null Seeded options array, or null when the property is absent / non-array.
261 */
262 private function readPluginLogicOptionsProperty($pluginLogic) {
263 try {
264 $optionsProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'options');
265 $options = $optionsProperty->getValue($pluginLogic);
266 if (!is_array($options)) {
267 return null;
268 }
269 /** @var array<string, mixed> $typed */
270 $typed = $options;
271 return $typed;
272 } catch (Throwable $e) {
273 $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$options via reflection (' . $e->getMessage() . '); falling through to subclass-getOptions seam.');
274 return null;
275 }
276 }
277
278 /**
279 * Invoke a test-installed PluginLogic singleton's getOptions(true) override.
280 * Real PluginLogic has no getOptions() method (removed in b2ab795d) so this
281 * is only reached when a test subclass installs one as the singleton.
282 *
283 * @param object $pluginLogic Test stub with a getOptions(bool) method.
284 * @return array<string, mixed>|null Subclass-provided options, or null when the override raised.
285 */
286 private function callPluginLogicGetOptions($pluginLogic) {
287 try {
288 /** @var callable $callable */
289 $callable = array($pluginLogic, 'getOptions');
290 $maybeOptions = call_user_func($callable, true);
291 if (!is_array($maybeOptions)) {
292 return null;
293 }
294 /** @var array<string, mixed> $typed */
295 $typed = $maybeOptions;
296 return $typed;
297 } catch (Throwable $e) {
298 $this->warn('PluginLogicOptionsResolver subclass-getOptions seam raised (' . $e->getMessage() . '); falling back to WordPress options.');
299 return null;
300 }
301 }
302
303 private function warn(string $message): void {
304 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
305 if (is_object($logger) && method_exists($logger, 'warn')) {
306 $logger->warn($message);
307 return;
308 }
309
310 abj404_logPhpFallback('service-resolution-fallback', $message);
311 }
312 }
313