Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.10% covered (success)
93.10%
135 / 145
88.46% covered (warning)
88.46%
23 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConfigSchemaAggregator
93.10% covered (success)
93.10%
135 / 145
88.46% covered (warning)
88.46%
23 / 26
66.39
0.00% covered (danger)
0.00%
0 / 1
 addSchema
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setListValueInternal
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 addSchemaMulti
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mergeListInternal
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 addDefaults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addMergeStrategies
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 addDynamicDefaults
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getDefinedKeys
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getSchemaFor
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 hasSchemaFor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 getDefaults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMergeStrategyNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDynamicDefaults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDefaultFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTypeFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDynamicDefaultDeclarationFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMergeStrategyFor
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMergeStrategies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 initMergeStrategies
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getStrategyForType
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
 validateConfig
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 validateValue
86.21% covered (warning)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
12.38
 hasNumericKeys
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3namespace MediaWiki\Settings\Config;
4
5use JsonSchema\Constraints\Constraint;
6use JsonSchema\Validator;
7use MediaWiki\Config\Config;
8use MediaWiki\Settings\DynamicDefaultValues;
9use MediaWiki\Settings\SettingsBuilderException;
10use MediaWiki\Settings\Source\JsonSchemaTrait;
11use StatusValue;
12use 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 */
21class 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}