519 lines
13 KiB
PHP
519 lines
13 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Settings\Config;
|
|
|
|
use JsonSchema\Constraints\Constraint;
|
|
use JsonSchema\Validator;
|
|
use MediaWiki\Config\Config;
|
|
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;
|
|
}
|
|
|
|
}
|