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