wikimedia/mediawiki-core

View on GitHub
includes/Settings/Config/ConfigSchemaAggregator.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?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;
    }

}