Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
93.10% |
135 / 145 |
|
88.46% |
23 / 26 |
CRAP | |
0.00% |
0 / 1 |
ConfigSchemaAggregator | |
93.10% |
135 / 145 |
|
88.46% |
23 / 26 |
66.39 | |
0.00% |
0 / 1 |
addSchema | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
setListValueInternal | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
addSchemaMulti | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
mergeListInternal | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
addDefaults | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addTypes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addMergeStrategies | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
addDynamicDefaults | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getDefinedKeys | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getSchemaFor | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
hasSchemaFor | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
getDefaults | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTypes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMergeStrategyNames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDynamicDefaults | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
hasDefaultFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDefaultFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTypeFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getDynamicDefaultDeclarationFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMergeStrategyFor | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getMergeStrategies | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
initMergeStrategies | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
getStrategyForType | |
50.00% |
5 / 10 |
|
0.00% |
0 / 1 |
10.50 | |||
validateConfig | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
validateValue | |
86.21% |
25 / 29 |
|
0.00% |
0 / 1 |
12.38 | |||
hasNumericKeys | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Settings\Config; |
4 | |
5 | use JsonSchema\Constraints\Constraint; |
6 | use JsonSchema\Validator; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Settings\DynamicDefaultValues; |
9 | use MediaWiki\Settings\SettingsBuilderException; |
10 | use MediaWiki\Settings\Source\JsonSchemaTrait; |
11 | use StatusValue; |
12 | use function array_key_exists; |
13 | |
14 | /** |
15 | * Aggregates multiple config schemas. |
16 | * |
17 | * Some aspects of the schema are maintained separately, to optimized |
18 | * for settings defaults, types and merge strategies in bulk, and later |
19 | * accessing them independently of each other, for each config key. |
20 | */ |
21 | class ConfigSchemaAggregator implements ConfigSchema { |
22 | use JsonSchemaTrait; |
23 | |
24 | /** @var array[] Maps config keys to JSON schema structures */ |
25 | private $schemas = []; |
26 | |
27 | /** @var array Map of config keys to default values, for optimized access */ |
28 | private $defaults = []; |
29 | |
30 | /** @var array Map of config keys to dynamic default declaration ararys, for optimized access */ |
31 | private $dynamicDefaults = []; |
32 | |
33 | /** @var array Map of config keys to types, for optimized access */ |
34 | private $types = []; |
35 | |
36 | /** @var array Map of config keys to merge strategies, for optimized access */ |
37 | private $mergeStrategies = []; |
38 | |
39 | /** @var MergeStrategy[]|null */ |
40 | private $mergeStrategyCache; |
41 | |
42 | /** @var Validator */ |
43 | private $validator; |
44 | |
45 | /** |
46 | * Add a config schema to the aggregator. |
47 | * |
48 | * @param string $key |
49 | * @param array $schema |
50 | * @param string $sourceName |
51 | */ |
52 | public function addSchema( string $key, array $schema, string $sourceName = 'unknown' ) { |
53 | if ( isset( $schema['properties'] ) ) { |
54 | // Collect the defaults of nested property declarations into the top level default. |
55 | $schema['default'] = self::getDefaultFromJsonSchema( $schema ); |
56 | } |
57 | |
58 | $this->schemas[$key] = $schema; |
59 | |
60 | $this->setListValueInternal( $schema, $this->defaults, $key, 'default', $sourceName ); |
61 | $this->setListValueInternal( $schema, $this->types, $key, 'type', $sourceName ); |
62 | $this->setListValueInternal( $schema, $this->mergeStrategies, $key, 'mergeStrategy', $sourceName ); |
63 | $this->setListValueInternal( $schema, $this->dynamicDefaults, $key, 'dynamicDefault', $sourceName ); |
64 | |
65 | if ( isset( $schema['mergeStrategy'] ) ) { |
66 | // TODO: mark cache as incomplete rather than throwing it away |
67 | $this->mergeStrategyCache = null; |
68 | } |
69 | } |
70 | |
71 | /** |
72 | * Update a map with a specific field. |
73 | * |
74 | * @param array $schema |
75 | * @param array &$target |
76 | * @param string $key |
77 | * @param string $fieldName |
78 | * @param string $sourceName |
79 | * |
80 | * @return void |
81 | * @throws SettingsBuilderException if a conflict is detected |
82 | * |
83 | */ |
84 | private function setListValueInternal( $schema, &$target, $key, $fieldName, $sourceName ) { |
85 | if ( array_key_exists( $fieldName, $schema ) ) { |
86 | if ( array_key_exists( $key, $target ) ) { |
87 | throw new SettingsBuilderException( |
88 | "Overriding $fieldName in schema for {key} from {source}", |
89 | [ |
90 | 'source' => $sourceName, |
91 | 'key' => $key, |
92 | ] |
93 | ); |
94 | } |
95 | $target[$key] = $schema[$fieldName]; |
96 | } |
97 | } |
98 | |
99 | /** |
100 | * Add multiple schema definitions. |
101 | * |
102 | * @see addSchema() |
103 | * |
104 | * @param array[] $schemas An associative array mapping config variable |
105 | * names to their respective schemas. |
106 | */ |
107 | public function addSchemaMulti( array $schemas ) { |
108 | foreach ( $schemas as $key => $sch ) { |
109 | $this->addSchema( $key, $sch ); |
110 | } |
111 | } |
112 | |
113 | /** |
114 | * Update a map with the given values. |
115 | * |
116 | * @param array $values |
117 | * @param array &$target |
118 | * @param string $fieldName |
119 | * @param string $sourceName |
120 | * |
121 | * @throws SettingsBuilderException if a conflict is detected |
122 | * |
123 | * @return void |
124 | */ |
125 | private function mergeListInternal( $values, &$target, $fieldName, $sourceName ) { |
126 | $merged = array_merge( $target, $values ); |
127 | if ( count( $merged ) < ( count( $target ) + count( $values ) ) ) { |
128 | throw new SettingsBuilderException( 'Overriding config {field} from {source}', [ |
129 | 'field' => $fieldName, |
130 | 'source' => $sourceName, |
131 | 'old_values' => implode( ', ', array_intersect_key( $target, $values ) ), |
132 | 'new_values' => implode( ', ', array_intersect_key( $values, $target ) ), |
133 | ] ); |
134 | } |
135 | |
136 | $target = $merged; |
137 | } |
138 | |
139 | /** |
140 | * Declare default values |
141 | * |
142 | * @param array $defaults |
143 | * @param string $sourceName |
144 | */ |
145 | public function addDefaults( array $defaults, string $sourceName = 'unknown' ) { |
146 | $this->mergeListInternal( $defaults, $this->defaults, 'defaults', $sourceName ); |
147 | } |
148 | |
149 | /** |
150 | * Declare types |
151 | * |
152 | * @param array $types |
153 | * @param string $sourceName |
154 | */ |
155 | public function addTypes( array $types, string $sourceName = 'unknown' ) { |
156 | $this->mergeListInternal( $types, $this->types, 'types', $sourceName ); |
157 | } |
158 | |
159 | /** |
160 | * Declare merge strategies |
161 | * |
162 | * @param array $mergeStrategies |
163 | * @param string $sourceName |
164 | */ |
165 | public function addMergeStrategies( array $mergeStrategies, string $sourceName = 'unknown' ) { |
166 | $this->mergeListInternal( |
167 | $mergeStrategies, |
168 | $this->mergeStrategies, |
169 | 'mergeStrategies', |
170 | $sourceName |
171 | ); |
172 | |
173 | // TODO: mark cache as incomplete rather than throwing it away |
174 | $this->mergeStrategyCache = null; |
175 | } |
176 | |
177 | /** |
178 | * Declare dynamic defaults |
179 | * |
180 | * @see DynamicDefaultValues. |
181 | * |
182 | * @param array $dynamicDefaults |
183 | * @param string $sourceName |
184 | */ |
185 | public function addDynamicDefaults( array $dynamicDefaults, string $sourceName = 'unknown' ) { |
186 | $this->mergeListInternal( |
187 | $dynamicDefaults, |
188 | $this->dynamicDefaults, |
189 | 'dynamicDefaults', |
190 | $sourceName |
191 | ); |
192 | } |
193 | |
194 | /** |
195 | * Get a list of all defined keys |
196 | * |
197 | * @return string[] |
198 | */ |
199 | public function getDefinedKeys(): array { |
200 | return array_keys( |
201 | array_merge( |
202 | $this->schemas, |
203 | $this->defaults, |
204 | $this->types, |
205 | $this->mergeStrategies, |
206 | $this->dynamicDefaults |
207 | ) |
208 | ); |
209 | } |
210 | |
211 | /** |
212 | * Get the schema for the given key |
213 | * |
214 | * @param string $key |
215 | * |
216 | * @return array |
217 | */ |
218 | public function getSchemaFor( string $key ): array { |
219 | $schema = $this->schemas[$key] ?? []; |
220 | |
221 | if ( isset( $this->defaults[$key] ) ) { |
222 | $schema['default'] = $this->defaults[$key]; |
223 | } |
224 | |
225 | if ( isset( $this->types[$key] ) ) { |
226 | $schema['type'] = $this->types[$key]; |
227 | } |
228 | |
229 | if ( isset( $this->mergeStrategies[$key] ) ) { |
230 | $schema['mergeStrategy'] = $this->mergeStrategies[$key]; |
231 | } |
232 | |
233 | if ( isset( $this->dynamicDefaults[$key] ) ) { |
234 | $schema['dynamicDefault'] = $this->dynamicDefaults[$key]; |
235 | } |
236 | |
237 | return $schema; |
238 | } |
239 | |
240 | /** |
241 | * Check whether schema for $key is defined. |
242 | * |
243 | * @param string $key |
244 | * @return bool |
245 | */ |
246 | public function hasSchemaFor( string $key ): bool { |
247 | return isset( $this->schemas[ $key ] ) |
248 | || array_key_exists( $key, $this->defaults ) |
249 | || isset( $this->types[ $key ] ) |
250 | || isset( $this->mergeStrategies[ $key ] ) |
251 | || isset( $this->dynamicDefaults[ $key ] ); |
252 | } |
253 | |
254 | /** |
255 | * Get all defined default values. |
256 | * |
257 | * @return array |
258 | */ |
259 | public function getDefaults(): array { |
260 | return $this->defaults; |
261 | } |
262 | |
263 | /** |
264 | * Get all known types. |
265 | * |
266 | * @return array<string|array> |
267 | */ |
268 | public function getTypes(): array { |
269 | return $this->types; |
270 | } |
271 | |
272 | /** |
273 | * Get the names of all known merge strategies. |
274 | * |
275 | * @return array<string> |
276 | */ |
277 | public function getMergeStrategyNames(): array { |
278 | return $this->mergeStrategies; |
279 | } |
280 | |
281 | /** |
282 | * Get all dynamic default declarations. |
283 | * @see DynamicDefaultValues. |
284 | * |
285 | * @return array<string,array> |
286 | */ |
287 | public function getDynamicDefaults(): array { |
288 | return $this->dynamicDefaults; |
289 | } |
290 | |
291 | /** |
292 | * Check if the $key has a default values set in the schema. |
293 | * |
294 | * @param string $key |
295 | * @return bool |
296 | */ |
297 | public function hasDefaultFor( string $key ): bool { |
298 | return array_key_exists( $key, $this->defaults ); |
299 | } |
300 | |
301 | /** |
302 | * Get default value for the $key. |
303 | * If no default value was declared, this returns null. |
304 | * |
305 | * @param string $key |
306 | * @return mixed |
307 | */ |
308 | public function getDefaultFor( string $key ) { |
309 | return $this->defaults[$key] ?? null; |
310 | } |
311 | |
312 | /** |
313 | * Get type for the $key, or null if the type is not known. |
314 | * |
315 | * @param string $key |
316 | * @return mixed |
317 | */ |
318 | public function getTypeFor( string $key ) { |
319 | return $this->types[$key] ?? null; |
320 | } |
321 | |
322 | /** |
323 | * Get a dynamic default declaration for $key. |
324 | * If no dynamic default is declared, this returns null. |
325 | * |
326 | * @param string $key |
327 | * @return ?array An associative array of the form expected by DynamicDefaultValues. |
328 | */ |
329 | public function getDynamicDefaultDeclarationFor( string $key ): ?array { |
330 | return $this->dynamicDefaults[$key] ?? null; |
331 | } |
332 | |
333 | /** |
334 | * Get the merge strategy defined for the $key, or null if none defined. |
335 | * |
336 | * @param string $key |
337 | * @return MergeStrategy|null |
338 | * @throws SettingsBuilderException if merge strategy name is invalid. |
339 | */ |
340 | public function getMergeStrategyFor( string $key ): ?MergeStrategy { |
341 | if ( $this->mergeStrategyCache === null ) { |
342 | $this->initMergeStrategies(); |
343 | } |
344 | return $this->mergeStrategyCache[$key] ?? null; |
345 | } |
346 | |
347 | /** |
348 | * Get all merge strategies indexed by config key. If there is no merge |
349 | * strategy for a given key, the element will be absent. |
350 | * |
351 | * @return MergeStrategy[] |
352 | */ |
353 | public function getMergeStrategies() { |
354 | if ( $this->mergeStrategyCache === null ) { |
355 | $this->initMergeStrategies(); |
356 | } |
357 | return $this->mergeStrategyCache; |
358 | } |
359 | |
360 | /** |
361 | * Initialise $this->mergeStrategyCache |
362 | */ |
363 | private function initMergeStrategies() { |
364 | // XXX: Keep $strategiesByName for later, in case we reset the cache? |
365 | // Or we could make a bulk version of MergeStrategy::newFromName(), |
366 | // to make use of the cache there without the overhead of a method |
367 | // call for each setting. |
368 | |
369 | $strategiesByName = []; |
370 | $strategiesByKey = []; |
371 | |
372 | // Explicitly defined merge strategies |
373 | $strategyNamesByKey = $this->mergeStrategies; |
374 | |
375 | // Loop over settings for which we know a type but not a merge strategy, |
376 | // so we can add a merge strategy for them based on their type. |
377 | $types = array_diff_key( $this->types, $strategyNamesByKey ); |
378 | foreach ( $types as $key => $type ) { |
379 | $strategyNamesByKey[$key] = self::getStrategyForType( $type ); |
380 | } |
381 | |
382 | // Assign MergeStrategy objects to settings. Create only one object per strategy name. |
383 | foreach ( $strategyNamesByKey as $key => $strategyName ) { |
384 | if ( !array_key_exists( $strategyName, $strategiesByName ) ) { |
385 | $strategiesByName[$strategyName] = MergeStrategy::newFromName( $strategyName ); |
386 | } |
387 | $strategiesByKey[$key] = $strategiesByName[$strategyName]; |
388 | } |
389 | |
390 | $this->mergeStrategyCache = $strategiesByKey; |
391 | } |
392 | |
393 | /** |
394 | * Returns an appropriate merge strategy for the given type. |
395 | * |
396 | * @param string|array $type |
397 | * |
398 | * @return string |
399 | */ |
400 | private static function getStrategyForType( $type ) { |
401 | if ( is_array( $type ) ) { |
402 | if ( in_array( 'array', $type ) ) { |
403 | $type = 'array'; |
404 | } elseif ( in_array( 'object', $type ) ) { |
405 | $type = 'object'; |
406 | } |
407 | } |
408 | |
409 | if ( $type === 'array' ) { |
410 | // In JSON Schema, "array" means a list. |
411 | // Use array_merge to append. |
412 | return 'array_merge'; |
413 | } elseif ( $type === 'object' ) { |
414 | // In JSON Schema, "object" means a map. |
415 | // Use array_plus to replace keys, even if they are numeric. |
416 | return 'array_plus'; |
417 | } |
418 | |
419 | return 'replace'; |
420 | } |
421 | |
422 | /** |
423 | * Check if the given config conforms to the schema. |
424 | * Note that all keys for which a schema was defined are required to be present in $config. |
425 | * |
426 | * @param Config $config |
427 | * |
428 | * @return StatusValue |
429 | */ |
430 | public function validateConfig( Config $config ): StatusValue { |
431 | $result = StatusValue::newGood(); |
432 | |
433 | foreach ( $this->getDefinedKeys() as $key ) { |
434 | // All config keys present in the schema must be set. |
435 | if ( !$config->has( $key ) ) { |
436 | $result->fatal( 'config-missing-key', $key ); |
437 | continue; |
438 | } |
439 | |
440 | $value = $config->get( $key ); |
441 | $result->merge( $this->validateValue( $key, $value ) ); |
442 | } |
443 | return $result; |
444 | } |
445 | |
446 | /** |
447 | * Check if the given value conforms to the relevant schema. |
448 | * |
449 | * @param string $key |
450 | * @param mixed $value |
451 | * |
452 | * @return StatusValue |
453 | */ |
454 | public function validateValue( string $key, $value ): StatusValue { |
455 | $status = StatusValue::newGood(); |
456 | $schema = $this->getSchemaFor( $key ); |
457 | |
458 | if ( !$schema ) { |
459 | return $status; |
460 | } |
461 | |
462 | if ( !$this->validator ) { |
463 | $this->validator = new Validator(); |
464 | } |
465 | |
466 | $types = isset( $schema['type'] ) ? (array)$schema['type'] : []; |
467 | |
468 | if ( in_array( 'object', $types ) && is_array( $value ) ) { |
469 | if ( $this->hasNumericKeys( $value ) ) { |
470 | // JSON Schema validation doesn't like numeric keys in objects, |
471 | // but we need this quite a bit. Skip type validation in this case. |
472 | $status->warning( |
473 | 'config-invalid-key', |
474 | $key, |
475 | 'Skipping validation of object with integer keys' |
476 | ); |
477 | unset( $schema['type'] ); |
478 | } |
479 | } |
480 | |
481 | if ( in_array( 'integer', $types ) && is_float( $value ) ) { |
482 | // The validator complains about float values when an integer is expected, |
483 | // even when the fractional part is 0. So cast to integer to avoid spurious errors. |
484 | $intval = intval( $value ); |
485 | if ( $intval == $value ) { |
486 | $value = $intval; |
487 | } |
488 | } |
489 | |
490 | $this->validator->validate( |
491 | $value, |
492 | $schema, |
493 | Constraint::CHECK_MODE_TYPE_CAST |
494 | ); |
495 | if ( !$this->validator->isValid() ) { |
496 | foreach ( $this->validator->getErrors() as $error ) { |
497 | $status->fatal( 'config-invalid-key', $key, $error['message'], var_export( $value, true ) ); |
498 | } |
499 | } |
500 | $this->validator->reset(); |
501 | return $status; |
502 | } |
503 | |
504 | /** |
505 | * @param array $value |
506 | * |
507 | * @return bool |
508 | */ |
509 | private function hasNumericKeys( array $value ) { |
510 | foreach ( $value as $key => $dummy ) { |
511 | if ( is_int( $key ) ) { |
512 | return true; |
513 | } |
514 | } |
515 | |
516 | return false; |
517 | } |
518 | |
519 | } |