wiki.techinc.nl/includes/Settings/Config/ConfigSchemaAggregator.php
Aryeh Gregor b72b9a8c43 Move dynamic defaults into MainConfigSchema
The goal is to keep the actual default values for settings in the same
place as the setting is declared, and applied using the regular means
for loading the settings -- not in a separate piece of code that needs
to be loaded through some entirely different mechanism.

SetupDynamicConfig.php now contains a few categories of things:

* Post-processing of configuration settings, where already-set settings
  are altered. This could be moved to MainConfigSchema too as a separate
  set of methods.
* Processing of old aliases of settings (blacklist, slave) that are not
  registered as settings anymore and therefore are not available to
  MainConfigSchema. This could perhaps be moved to LocalSettings
  processing somehow?
* Setting $wgUseEnotif, which is also not registered as a setting.
  Easiest would be just to declare it as a setting and have it set
  unconditionally.
* Setting the actual timezone to $wgLocaltimezone. This is not related
  to configuration and should just be in Setup.php.

Bug: T305093
Change-Id: Ia5c23b52dbbfcb3d07ffcf5d3b7f2d7befba2a26
2022-07-07 09:55:48 +10:00

519 lines
13 KiB
PHP

<?php
namespace MediaWiki\Settings\Config;
use Config;
use JsonSchema\Constraints\Constraint;
use JsonSchema\Validator;
use MediaWiki\Settings\DynamicDefaultValues;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\JsonSchemaTrait;
use StatusValue;
use function array_key_exists;
/**
* Aggregates multiple config schemas.
*
* Some aspects of the schema are maintained separately, to optimized
* for settings defaults, types and merge strategies in bulk, and later
* accessing them independently of each other, for each config key.
*/
class ConfigSchemaAggregator implements ConfigSchema {
use JsonSchemaTrait;
/** @var array[] Maps config keys to JSON schema structures */
private $schemas = [];
/** @var array Map of config keys to default values, for optimized access */
private $defaults = [];
/** @var array Map of config keys to dynamic default declaration ararys, for optimized access */
private $dynamicDefaults = [];
/** @var array Map of config keys to types, for optimized access */
private $types = [];
/** @var array Map of config keys to merge strategies, for optimized access */
private $mergeStrategies = [];
/** @var MergeStrategy[]|null */
private $mergeStrategyCache;
/** @var Validator */
private $validator;
/**
* Add a config schema to the aggregator.
*
* @param string $key
* @param array $schema
* @param string $sourceName
*/
public function addSchema( string $key, array $schema, string $sourceName = 'unknown' ) {
if ( isset( $schema['properties'] ) ) {
// Collect the defaults of nested property declarations into the top level default.
$schema['default'] = self::getDefaultFromJsonSchema( $schema );
}
$this->schemas[$key] = $schema;
$this->setListValueInternal( $schema, $this->defaults, $key, 'default', $sourceName );
$this->setListValueInternal( $schema, $this->types, $key, 'type', $sourceName );
$this->setListValueInternal( $schema, $this->mergeStrategies, $key, 'mergeStrategy', $sourceName );
$this->setListValueInternal( $schema, $this->dynamicDefaults, $key, 'dynamicDefault', $sourceName );
if ( isset( $schema['mergeStrategy'] ) ) {
// TODO: mark cache as incomplete rather than throwing it away
$this->mergeStrategyCache = null;
}
}
/**
* Update a map with a specific field.
*
* @param array $schema
* @param array &$target
* @param string $key
* @param string $fieldName
* @param string $sourceName
*
* @return void
* @throws SettingsBuilderException if a conflict is detected
*
*/
private function setListValueInternal( $schema, &$target, $key, $fieldName, $sourceName ) {
if ( array_key_exists( $fieldName, $schema ) ) {
if ( array_key_exists( $key, $target ) ) {
throw new SettingsBuilderException(
"Overriding $fieldName in schema for {key} from {source}",
[
'source' => $sourceName,
'key' => $key,
]
);
}
$target[$key] = $schema[$fieldName];
}
}
/**
* Add multiple schema definitions.
*
* @see addSchema()
*
* @param array[] $schemas An associative array mapping config variable
* names to their respective schemas.
*/
public function addSchemaMulti( array $schemas ) {
foreach ( $schemas as $key => $sch ) {
$this->addSchema( $key, $sch );
}
}
/**
* Update a map with the given values.
*
* @param array $values
* @param array &$target
* @param string $fieldName
* @param string $sourceName
*
* @throws SettingsBuilderException if a conflict is detected
*
* @return void
*/
private function mergeListInternal( $values, &$target, $fieldName, $sourceName ) {
$merged = array_merge( $target, $values );
if ( count( $merged ) < ( count( $target ) + count( $values ) ) ) {
throw new SettingsBuilderException( 'Overriding config {field} from {source}', [
'field' => $fieldName,
'source' => $sourceName,
'old_values' => implode( ', ', array_intersect_key( $target, $values ) ),
'new_values' => implode( ', ', array_intersect_key( $values, $target ) ),
] );
}
$target = $merged;
}
/**
* Declare default values
*
* @param array $defaults
* @param string $sourceName
*/
public function addDefaults( array $defaults, string $sourceName = 'unknown' ) {
$this->mergeListInternal( $defaults, $this->defaults, 'defaults', $sourceName );
}
/**
* Declare types
*
* @param array $types
* @param string $sourceName
*/
public function addTypes( array $types, string $sourceName = 'unknown' ) {
$this->mergeListInternal( $types, $this->types, 'types', $sourceName );
}
/**
* Declare merge strategies
*
* @param array $mergeStrategies
* @param string $sourceName
*/
public function addMergeStrategies( array $mergeStrategies, string $sourceName = 'unknown' ) {
$this->mergeListInternal(
$mergeStrategies,
$this->mergeStrategies,
'mergeStrategies',
$sourceName
);
// TODO: mark cache as incomplete rather than throwing it away
$this->mergeStrategyCache = null;
}
/**
* Declare dynamic defaults
*
* @see DynamicDefaultValues.
*
* @param array $dynamicDefaults
* @param string $sourceName
*/
public function addDynamicDefaults( array $dynamicDefaults, string $sourceName = 'unknown' ) {
$this->mergeListInternal(
$dynamicDefaults,
$this->dynamicDefaults,
'dynamicDefaults',
$sourceName
);
}
/**
* Get a list of all defined keys
*
* @return string[]
*/
public function getDefinedKeys(): array {
return array_keys(
array_merge(
$this->schemas,
$this->defaults,
$this->types,
$this->mergeStrategies,
$this->dynamicDefaults
)
);
}
/**
* Get the schema for the given key
*
* @param string $key
*
* @return array
*/
public function getSchemaFor( string $key ): array {
$schema = $this->schemas[$key] ?? [];
if ( isset( $this->defaults[$key] ) ) {
$schema['default'] = $this->defaults[$key];
}
if ( isset( $this->types[$key] ) ) {
$schema['type'] = $this->types[$key];
}
if ( isset( $this->mergeStrategies[$key] ) ) {
$schema['mergeStrategy'] = $this->mergeStrategies[$key];
}
if ( isset( $this->dynamicDefaults[$key] ) ) {
$schema['dynamicDefault'] = $this->dynamicDefaults[$key];
}
return $schema;
}
/**
* Check whether schema for $key is defined.
*
* @param string $key
* @return bool
*/
public function hasSchemaFor( string $key ): bool {
return isset( $this->schemas[ $key ] )
|| array_key_exists( $key, $this->defaults )
|| isset( $this->types[ $key ] )
|| isset( $this->mergeStrategies[ $key ] )
|| isset( $this->dynamicDefaults[ $key ] );
}
/**
* Get all defined default values.
*
* @return array
*/
public function getDefaults(): array {
return $this->defaults;
}
/**
* Get all known types.
*
* @return array<string|array>
*/
public function getTypes(): array {
return $this->types;
}
/**
* Get the names of all known merge strategies.
*
* @return array<string>
*/
public function getMergeStrategyNames(): array {
return $this->mergeStrategies;
}
/**
* Get all dynamic default declarations.
* @see DynamicDefaultValues.
*
* @return array<string,array>
*/
public function getDynamicDefaults(): array {
return $this->dynamicDefaults;
}
/**
* Check if the $key has a default values set in the schema.
*
* @param string $key
* @return bool
*/
public function hasDefaultFor( string $key ): bool {
return array_key_exists( $key, $this->defaults );
}
/**
* Get default value for the $key.
* If no default value was declared, this returns null.
*
* @param string $key
* @return mixed
*/
public function getDefaultFor( string $key ) {
return $this->defaults[$key] ?? null;
}
/**
* Get type for the $key, or null if the type is not known.
*
* @param string $key
* @return mixed
*/
public function getTypeFor( string $key ) {
return $this->types[$key] ?? null;
}
/**
* Get a dynamic default declaration for $key.
* If no dynamic default is declared, this returns null.
*
* @param string $key
* @return ?array An associative array of the form expected by DynamicDefaultValues.
*/
public function getDynamicDefaultDeclarationFor( string $key ): ?array {
return $this->dynamicDefaults[$key] ?? null;
}
/**
* Get the merge strategy defined for the $key, or null if none defined.
*
* @param string $key
* @return MergeStrategy|null
* @throws SettingsBuilderException if merge strategy name is invalid.
*/
public function getMergeStrategyFor( string $key ): ?MergeStrategy {
if ( $this->mergeStrategyCache === null ) {
$this->initMergeStrategies();
}
return $this->mergeStrategyCache[$key] ?? null;
}
/**
* Get all merge strategies indexed by config key. If there is no merge
* strategy for a given key, the element will be absent.
*
* @return MergeStrategy[]
*/
public function getMergeStrategies() {
if ( $this->mergeStrategyCache === null ) {
$this->initMergeStrategies();
}
return $this->mergeStrategyCache;
}
/**
* Initialise $this->mergeStrategyCache
*/
private function initMergeStrategies() {
// XXX: Keep $strategiesByName for later, in case we reset the cache?
// Or we could make a bulk version of MergeStrategy::newFromName(),
// to make use of the cache there without the overhead of a method
// call for each setting.
$strategiesByName = [];
$strategiesByKey = [];
// Explicitly defined merge strategies
$strategyNamesByKey = $this->mergeStrategies;
// Loop over settings for which we know a type but not a merge strategy,
// so we can add a merge strategy for them based on their type.
$types = array_diff_key( $this->types, $strategyNamesByKey );
foreach ( $types as $key => $type ) {
$strategyNamesByKey[$key] = self::getStrategyForType( $type );
}
// Assign MergeStrategy objects to settings. Create only one object per strategy name.
foreach ( $strategyNamesByKey as $key => $strategyName ) {
if ( !array_key_exists( $strategyName, $strategiesByName ) ) {
$strategiesByName[$strategyName] = MergeStrategy::newFromName( $strategyName );
}
$strategiesByKey[$key] = $strategiesByName[$strategyName];
}
$this->mergeStrategyCache = $strategiesByKey;
}
/**
* Returns an appropriate merge strategy for the given type.
*
* @param string|array $type
*
* @return string
*/
private static function getStrategyForType( $type ) {
if ( is_array( $type ) ) {
if ( in_array( 'array', $type ) ) {
$type = 'array';
} elseif ( in_array( 'object', $type ) ) {
$type = 'object';
}
}
if ( $type === 'array' ) {
// In JSON Schema, "array" means a list.
// Use array_merge to append.
return 'array_merge';
} elseif ( $type === 'object' ) {
// In JSON Schema, "object" means a map.
// Use array_plus to replace keys, even if they are numeric.
return 'array_plus';
}
return 'replace';
}
/**
* Check if the given config conforms to the schema.
* Note that all keys for which a schema was defined are required to be present in $config.
*
* @param Config $config
*
* @return StatusValue
*/
public function validateConfig( Config $config ): StatusValue {
$result = StatusValue::newGood();
foreach ( $this->getDefinedKeys() as $key ) {
// All config keys present in the schema must be set.
if ( !$config->has( $key ) ) {
$result->fatal( 'config-missing-key', $key );
continue;
}
$value = $config->get( $key );
$result->merge( $this->validateValue( $key, $value ) );
}
return $result;
}
/**
* Check if the given value conforms to the relevant schema.
*
* @param string $key
* @param mixed $value
*
* @return StatusValue
*/
public function validateValue( string $key, $value ): StatusValue {
$status = StatusValue::newGood();
$schema = $this->getSchemaFor( $key );
if ( !$schema ) {
return $status;
}
if ( !$this->validator ) {
$this->validator = new Validator();
}
$types = isset( $schema['type'] ) ? (array)$schema['type'] : [];
if ( in_array( 'object', $types ) && is_array( $value ) ) {
if ( $this->hasNumericKeys( $value ) ) {
// JSON Schema validation doesn't like numeric keys in objects,
// but we need this quite a bit. Skip type validation in this case.
$status->warning(
'config-invalid-key',
$key,
'Skipping validation of object with integer keys'
);
unset( $schema['type'] );
}
}
if ( in_array( 'integer', $types ) && is_float( $value ) ) {
// The validator complains about float values when an integer is expected,
// even when the fractional part is 0. So cast to integer to avoid spurious errors.
$intval = intval( $value );
if ( $intval == $value ) {
$value = $intval;
}
}
$this->validator->validate(
$value,
$schema,
Constraint::CHECK_MODE_TYPE_CAST
);
if ( !$this->validator->isValid() ) {
foreach ( $this->validator->getErrors() as $error ) {
$status->fatal( 'config-invalid-key', $key, $error['message'], var_export( $value, true ) );
}
}
$this->validator->reset();
return $status;
}
/**
* @param array $value
*
* @return bool
*/
private function hasNumericKeys( array $value ) {
foreach ( $value as $key => $dummy ) {
if ( is_int( $key ) ) {
return true;
}
}
return false;
}
}