Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.33% |
140 / 150 |
|
88.46% |
23 / 26 |
CRAP | |
0.00% |
0 / 1 |
| ConfigSchemaAggregator | |
93.33% |
140 / 150 |
|
88.46% |
23 / 26 |
70.41 | |
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 | |
88.24% |
30 / 34 |
|
0.00% |
0 / 1 |
16.42 | |||
| 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 | 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 | } |