From 007c0c776c227f05bfd01d8ab100296ee1b3f02b Mon Sep 17 00:00:00 2001 From: Alex Pott <alex.a.pott@googlemail.com> Date: Thu, 23 Nov 2023 14:13:19 +0000 Subject: [PATCH] Issue #3401883 by Wim Leers, phenaproxima, alexpott, borisson_, bircher: Introduce Mapping::getValidKeys(), ::getRequiredKeys() etc --- core/config/schema/core.data_types.schema.yml | 21 + .../lib/Drupal/Core/Config/Schema/Mapping.php | 265 +++++++++ .../Drupal/Core/Config/TypedConfigManager.php | 17 +- .../config/schema/ckeditor5.schema.yml | 2 +- ...g_schema_add_fallback_type_test.schema.yml | 2 + ...fig_schema_add_fallback_type_test.info.yml | 5 + .../KernelTests/Config/Schema/MappingTest.php | 558 ++++++++++++++++++ .../Core/Config/ConfigSchemaTest.php | 53 +- 8 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 core/modules/config/tests/config_schema_add_fallback_type_test/config/schema/config_schema_add_fallback_type_test.schema.yml create mode 100644 core/modules/config/tests/config_schema_add_fallback_type_test/config_schema_add_fallback_type_test.info.yml create mode 100644 core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index 8935e21ef151..20044615d33e 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -171,6 +171,8 @@ config_object: type: mapping mapping: _core: + # This only exists for merging configuration; it's not required. + requiredKey: false type: _core_config_info langcode: type: langcode @@ -273,6 +275,9 @@ theme_settings: type: boolean label: 'Use default' third_party_settings: + # Third party settings are always optional: they're an optional extension + # point. + requiredKey: false type: sequence label: 'Third party settings' sequence: @@ -298,6 +303,8 @@ config_dependencies_base: type: mapping mapping: config: + # All dependency keys are optional: this might not depend on any other config. + requiredKey: false type: sequence label: 'Configuration entity dependencies' sequence: @@ -306,11 +313,15 @@ config_dependencies_base: NotBlank: [] ConfigExists: [] content: + # All dependency keys are optional: this might not depend on any content entities. + requiredKey: false type: sequence label: 'Content entity dependencies' sequence: type: string module: + # All dependency keys are optional: this might not depend on any modules. + requiredKey: false type: sequence label: 'Module dependencies' sequence: @@ -320,6 +331,8 @@ config_dependencies_base: ExtensionName: [] ExtensionExists: module theme: + # All dependency keys are optional: this might not depend on any themes. + requiredKey: false type: sequence label: 'Theme dependencies' sequence: @@ -336,6 +349,8 @@ config_dependencies: label: 'Configuration dependencies' mapping: enforced: + # All dependency keys are optional: this may have no dependencies at all. + requiredKey: false type: config_dependencies_base label: 'Enforced configuration dependencies' constraints: @@ -356,11 +371,16 @@ config_entity: type: config_dependencies label: 'Dependencies' third_party_settings: + # Third party settings are always optional: they're an optional extension + # point. + requiredKey: false type: sequence label: 'Third party settings' sequence: type: '[%parent.%parent.%type].third_party.[%key]' _core: + # This only exists for merging configuration; it's not required. + requiredKey: false type: _core_config_info block.settings.*: @@ -452,6 +472,7 @@ layout_plugin.settings: sequence: type: string +# Specify defaults. layout_plugin.settings.*: type: layout_plugin.settings diff --git a/core/lib/Drupal/Core/Config/Schema/Mapping.php b/core/lib/Drupal/Core/Config/Schema/Mapping.php index 3fa470be170e..a0b305ee2649 100644 --- a/core/lib/Drupal/Core/Config/Schema/Mapping.php +++ b/core/lib/Drupal/Core/Config/Schema/Mapping.php @@ -2,6 +2,11 @@ namespace Drupal\Core\Config\Schema; +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\MapDataDefinition; +use Drupal\Core\TypedData\TypedDataInterface; + /** * Defines a mapping configuration element. * @@ -17,6 +22,27 @@ */ class Mapping extends ArrayElement { + /** + * {@inheritdoc} + */ + public function __construct(DataDefinitionInterface $definition, $name = NULL, TypedDataInterface $parent = NULL) { + assert($definition instanceof MapDataDefinition); + // Validate basic structure. + foreach ($definition['mapping'] as $key => $key_definition) { + // Guide developers when a config schema definition is wrong. + if (!is_array($key_definition)) { + if (!$parent) { + throw new \LogicException(sprintf("The mapping definition at `%s` is invalid: its `%s` key contains a %s. It must be an array.", $name, $key, gettype($key_definition))); + } + else { + throw new \LogicException(sprintf("The mapping definition at `%s:%s` is invalid: its `%s` key contains a %s. It must be an array.", $parent->getPropertyPath(), $name, $key, gettype($key_definition))); + } + } + } + $this->processRequiredKeyFlags($definition); + parent::__construct($definition, $name, $parent); + } + /** * {@inheritdoc} */ @@ -26,4 +52,243 @@ protected function getElementDefinition($key) { return $this->buildDataDefinition($definition, $value, $key); } + /** + * Gets all keys allowed in this mapping. + * + * @return string[] + * A list of keys allowed in this mapping. + */ + public function getValidKeys(): array { + $all_keys = $this->getDefinedKeys(); + return array_keys($all_keys); + } + + /** + * Gets all required keys in this mapping. + * + * @return string[] + * A list of keys required in this mapping. + */ + public function getRequiredKeys(): array { + $all_keys = $this->getDefinedKeys(); + $required_keys = array_filter( + $all_keys, + fn (array $schema_definition): bool => $schema_definition['requiredKey'] + ); + return array_keys($required_keys); + } + + /** + * Gets the keys defined for this mapping (locally defined + inherited). + * + * @return array + * Raw schema definitions: keys are mapping keys, values are their + * definitions. + */ + protected function getDefinedKeys(): array { + $definition = $this->getDataDefinition(); + return $definition->toArray()['mapping']; + } + + /** + * Gets all dynamically valid keys. + * + * When the `type` of the mapping is dynamic itself. For example: the settings + * associated with a FieldConfig depend on what kind of field it is (i.e., + * which field plugin it uses). + * + * Other examples: + * - CKEditor 5 uses 'ckeditor5.plugin.[%key]'; the mapping is stored in a + * sequence, and `[%key]` is replaced by the mapping's key in that sequence. + * - third party settings use '[%parent.%parent.%type].third_party.[%key]'; + * `[%parent.%parent.%type]` is replaced by the type of the mapping two + * levels up. For example, 'node.type.third_party.[%key]'. + * - field instances' default values have a type of + * 'field.value.[%parent.%parent.field_type]'. This uses the value of the + * `field_type` key from the mapping two levels up. + * - Views filters have a type of 'views.filter.[plugin_id]'; `[plugin_id]` is + * replaced by the value of the mapping's `plugin_id` key. + * + * In each of these examples, the mapping may have keys that are dynamically + * valid, meaning that which keys are considered valid may depend on other + * values in the tree. + * + * @return string[][] + * A list of dynamically valid keys. An array with: + * - a key for every possible resolved type + * - the corresponding value an array of the additional mapping keys that + * are supported for this resolved type + * + * @see \Drupal\Core\Config\TypedConfigManager::replaceName() + * @see \Drupal\Core\Config\TypedConfigManager::replaceVariable() + * @see https://www.drupal.org/files/ConfigSchemaCheatSheet2.0.pdf + */ + public function getDynamicallyValidKeys(): array { + $parent_data_def = $this->getParent()?->getDataDefinition(); + if ($parent_data_def === NULL) { + return []; + } + + // Use the parent data definition to determine the type of this mapping + // (including the dynamic placeholders). For example: + // - `editor.settings.[%parent.editor]` + // - `editor.image_upload_settings.[status]`. + $original_mapping_type = match (TRUE) { + $parent_data_def instanceof MapDataDefinition => $parent_data_def->toArray()['mapping'][$this->getName()]['type'], + $parent_data_def instanceof SequenceDataDefinition => $parent_data_def->toArray()['sequence']['type'], + default => throw new \LogicException('Invalid config schema detected.'), + }; + + // If this mapping's type isn't dynamic, there's nothing to do. + if (!str_contains($original_mapping_type, ']')) { + return []; + } + // Only third-party settings, which are optional by definition, start with + // a dynamic placeholder. + elseif (str_starts_with($original_mapping_type, '[')) { + return []; + } + + // Expand the dynamic placeholders to find all mapping types derived from + // the original mapping type. To continue the previous example: + // - `editor.settings.unicorn` + // - `editor.image_upload_settings.*` + // - `editor.image_upload_settings.1` + $possible_types = $this->getPossibleTypes($original_mapping_type); + + // TRICKY: it is tempting to not consider this a dynamic type if only one + // concrete type exists. But that would lead to different validation errors + // when modules are installed or uninstalled. + assert(!empty($possible_types)); + + // Determine all valid keys, across all possible types. + $typed_data_manager = $this->getTypedDataManager(); + $all_type_definitions = $typed_data_manager->getDefinitions(); + $possible_type_definitions = array_intersect_key($all_type_definitions, array_fill_keys($possible_types, TRUE)); + // TRICKY: \Drupal\Core\Config\TypedConfigManager::getDefinition() does the + // necessary resolving, but TypedConfigManager::getDefinitions() does not! 🤷â€â™‚ï¸ + // @see \Drupal\Core\Config\TypedConfigManager::getDefinitionWithReplacements() + // @see ::getValidKeys() + $valid_keys_per_type = []; + foreach (array_keys($possible_type_definitions) as $possible_type_name) { + $valid_keys_per_type[$possible_type_name] = array_keys($typed_data_manager->getDefinition($possible_type_name)['mapping'] ?? []); + } + + // From all valid keys across all types, get the ones for the fallback type: + // its keys are inherited by all type definitions and are therefore always + // ("statically") valid. Not all types have a fallback type. + // @see \Drupal\Core\Config\TypedConfigManager::getDefinitionWithReplacements() + $fallback_type = $typed_data_manager->findFallback($original_mapping_type); + $valid_keys_everywhere = array_intersect_key( + $valid_keys_per_type, + [$fallback_type => NULL], + ); + assert(count($valid_keys_everywhere) <= 1); + $statically_required_keys = NestedArray::mergeDeepArray($valid_keys_everywhere); + + // Now that statically valid keys are known, determine which valid keys are + // only valid in *some* cases: remove the statically valid keys from every + // per-type array of valid keys. + $valid_keys_some = array_diff_key($valid_keys_per_type, $valid_keys_everywhere); + $valid_keys_some_processed = array_map( + fn (array $keys) => array_values(array_filter($keys, fn (string $key) => !in_array($key, $statically_required_keys, TRUE))), + $valid_keys_some + ); + return $valid_keys_some_processed; + } + + /** + * Gets all optional keys in this mapping. + * + * @return string[] + * A list of optional keys given the values in this mapping. + */ + public function getOptionalKeys(): array { + return array_values(array_diff($this->getValidKeys(), $this->getRequiredKeys())); + } + + /** + * Validates optional `requiredKey` flags, guarantees one will be set. + * + * For each key-value pair: + * - If the `requiredKey` flag is set, it must be `false`, to avoid pointless + * information in the schema. + * - If the `requiredKey` flag is not set and the `deprecated` flag is set, + * this will set `requiredKey: false`: deprecated keys are always optional. + * - If the `requiredKey` flag is not set, nor the `deprecated` flag, + * will set `requiredKey: true`. + * + * @param \Drupal\Core\TypedData\MapDataDefinition $definition + * The config schema definition for a `type: mapping`. + * + * @return void + * + * @throws \LogicException + * Thrown when `requiredKey: true` is specified. + */ + protected function processRequiredKeyFlags(MapDataDefinition $definition): void { + foreach ($definition['mapping'] as $key => $key_definition) { + // Validates `requiredKey` flag in mapping definitions. + if (array_key_exists('requiredKey', $key_definition) && $key_definition['requiredKey'] !== FALSE) { + throw new \LogicException('The `requiredKey` flag must either be omitted or have `false` as the value.'); + } + // Generates the `requiredKey` flag if it is not set. + if (!array_key_exists('requiredKey', $key_definition)) { + // Required by default, unless this key is marked as deprecated. + // @see https://www.drupal.org/node/3129881 + $definition['mapping'][$key]['requiredKey'] = !array_key_exists('deprecated', $key_definition); + } + } + } + + /** + * Returns all possible types for the type with the given name. + * + * @param string $name + * Configuration name or key. + * + * @return string[] + * All possible types for a given type. For example, + * `core_date_format_pattern.[%parent.locked]` will return: + * - `core_date_format_pattern.0` + * - `core_date_format_pattern.1` + * If a fallback name is available, that will be returned too. In this + * example, that would be `core_date_format_pattern.*`. + */ + protected function getPossibleTypes(string $name): array { + // First, parse from e.g. + // `module.something.foo_[%parent.locked]` + // this: + // `[%parent.locked]` + // or from + // `[%parent.%parent.%type].third_party.[%key]` + // this: + // `[%parent.%parent.%type]` and `[%key]`. + // And collapse all these to just `[]`. + // @see \Drupal\Core\Config\TypedConfigManager::replaceVariable() + $matches = []; + if (preg_match_all('/(\[[^\]]+\])/', $name, $matches) >= 1) { + $name = str_replace($matches[0], '[]', $name); + } + // Then, replace all `[]` occurrences with `.*` and escape all periods for + // use in a regex. So: + // `module\.something\.foo_.*` + // or + // `.*\.third_party\..*` + $regex = str_replace(['.', '[]'], ['\.', '.*'], $name); + // Now find all possible types: + // 1. `module.something.foo_foo`, `module.something.foo_bar`, etc. + $possible_types = array_filter( + array_keys($this->getTypedDataManager()->getDefinitions()), + fn (string $type) => preg_match("/^$regex$/", $type) === 1 + ); + // 2. The fallback: `module.something.*` — if no concrete definition for it + // exists. + $fallback_type = $this->getTypedDataManager()->findFallback($name); + if ($fallback_type && !in_array($fallback_type, $possible_types, TRUE)) { + $possible_types[] = $fallback_type; + } + return $possible_types; + } + } diff --git a/core/lib/Drupal/Core/Config/TypedConfigManager.php b/core/lib/Drupal/Core/Config/TypedConfigManager.php index dcb328dc5d79..054247d06a3b 100644 --- a/core/lib/Drupal/Core/Config/TypedConfigManager.php +++ b/core/lib/Drupal/Core/Config/TypedConfigManager.php @@ -217,7 +217,7 @@ public function clearCachedDefinitions() { } /** - * Gets fallback configuration schema name. + * Finds fallback configuration schema name. * * @param string $name * Configuration name or key. @@ -243,6 +243,21 @@ public function clearCachedDefinitions() { * block.*.*:* * block.* */ + public function findFallback(string $name): ?string { + $fallback = $this->getFallbackName($name); + assert($fallback === NULL || str_ends_with($fallback, '.*')); + return $fallback; + } + + /** + * Gets fallback configuration schema name. + * + * @param string $name + * Configuration name or key. + * + * @return null|string + * The resolved schema name for the given configuration name or key. + */ protected function getFallbackName($name) { // Check for definition of $name with filesystem marker. $replaced = preg_replace('/([^\.:]+)([\.:\*]*)$/', '*\2', $name); diff --git a/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml b/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml index 4fe4a17da2df..2483c3cda4a7 100644 --- a/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml +++ b/core/modules/ckeditor5/config/schema/ckeditor5.schema.yml @@ -125,8 +125,8 @@ ckeditor5.plugin.ckeditor5_list: mapping: properties: type: mapping + label: 'Allowed list attributes' mapping: - label: 'Allowed list attributes' reversed: type: boolean label: 'Allow reverse list' diff --git a/core/modules/config/tests/config_schema_add_fallback_type_test/config/schema/config_schema_add_fallback_type_test.schema.yml b/core/modules/config/tests/config_schema_add_fallback_type_test/config/schema/config_schema_add_fallback_type_test.schema.yml new file mode 100644 index 000000000000..3d2e90a47d3c --- /dev/null +++ b/core/modules/config/tests/config_schema_add_fallback_type_test/config/schema/config_schema_add_fallback_type_test.schema.yml @@ -0,0 +1,2 @@ +condition.plugin.*: + type: condition.plugin diff --git a/core/modules/config/tests/config_schema_add_fallback_type_test/config_schema_add_fallback_type_test.info.yml b/core/modules/config/tests/config_schema_add_fallback_type_test/config_schema_add_fallback_type_test.info.yml new file mode 100644 index 000000000000..8386672ab634 --- /dev/null +++ b/core/modules/config/tests/config_schema_add_fallback_type_test/config_schema_add_fallback_type_test.info.yml @@ -0,0 +1,5 @@ +name: 'Configuration schema fallback type addition test' +type: module +package: Testing +version: VERSION +hidden: true diff --git a/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php new file mode 100644 index 000000000000..7094b2d2dc07 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Config/Schema/MappingTest.php @@ -0,0 +1,558 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\KernelTests\Config\Schema; + +// cspell:ignore childkey + +use Drupal\block\Entity\Block; +use Drupal\Core\Config\Schema\Mapping; +use Drupal\Core\TypedData\MapDataDefinition; +use Drupal\editor\Entity\Editor; +use Drupal\field\Entity\FieldConfig; +use Drupal\filter\Entity\FilterFormat; +use Drupal\KernelTests\KernelTestBase; + +/** + * @coversDefaultClass \Drupal\Core\Config\Schema\Mapping + * @group Config + */ +class MappingTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $configSchemaCheckerExclusions = [ + 'config_schema_deprecated_test.settings', + ]; + + /** + * @dataProvider providerMappingInterpretation + */ + public function testMappingInterpretation( + string $config_name, + ?string $property_path, + array $expected_valid_keys, + array $expected_optional_keys, + array $expected_dynamically_valid_keys, + ): void { + // Some config needs some dependencies installed. + switch ($config_name) { + case 'block.block.branding': + $this->enableModules(['system', 'block']); + /** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */ + $theme_installer = $this->container->get('theme_installer'); + $theme_installer->install(['stark']); + Block::create([ + 'id' => 'branding', + 'plugin' => 'system_branding_block', + 'theme' => 'stark', + 'status' => TRUE, + 'settings' => [ + 'use_site_logo' => TRUE, + 'use_site_name' => TRUE, + 'use_site_slogan' => TRUE, + 'label_display' => FALSE, + // TRICKY: these 4 are inherited from `type: block_settings`. + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + 'context_mapping' => [], + ], + ])->save(); + break; + + case 'block.block.local_tasks': + $this->enableModules(['system', 'block']); + /** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */ + $theme_installer = $this->container->get('theme_installer'); + $theme_installer->install(['stark']); + Block::create([ + 'id' => 'local_tasks', + 'plugin' => 'local_tasks_block', + 'theme' => 'stark', + 'status' => TRUE, + 'settings' => [ + 'primary' => TRUE, + 'secondary' => FALSE, + // TRICKY: these 4 are inherited from `type: block_settings`. + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + 'context_mapping' => [], + ], + ])->save(); + break; + + case 'block.block.positively_powered___alternate_reality_with_fallback_type___': + $this->enableModules(['config_schema_add_fallback_type_test']); + $id = 'positively_powered___alternate_reality_with_fallback_type___'; + case 'block.block.positively_powered': + $this->enableModules(['system', 'block']); + /** @var \Drupal\Core\Extension\ThemeInstallerInterface $theme_installer */ + $theme_installer = $this->container->get('theme_installer'); + $theme_installer->install(['stark']); + Block::create([ + 'id' => $id ?? 'positively_powered', + 'plugin' => 'system_powered_by_block', + 'theme' => 'stark', + 'status' => TRUE, + 'settings' => [ + 'label_display' => FALSE, + // TRICKY: these 4 are inherited from `type: block_settings`. + 'status' => TRUE, + 'info' => '', + 'view_mode' => 'full', + 'context_mapping' => [], + ], + // Avoid showing "Powered by Drupal" on 404 responses. + 'visibility' => [ + 'I_CAN_CHOOSE_THIS' => [ + // This is what determines the + 'id' => 'response_status', + 'negate' => FALSE, + 'status_codes' => [ + 404, + ], + ], + ], + ])->save(); + break; + + case 'config_schema_deprecated_test.settings': + $this->enableModules(['config_schema_deprecated_test']); + $config = $this->config('config_schema_deprecated_test.settings'); + // @see \Drupal\KernelTests\Core\Config\ConfigSchemaDeprecationTest + $config + ->set('complex_structure_deprecated.type', 'fruits') + ->set('complex_structure_deprecated.products', ['apricot', 'apple']) + ->save(); + break; + + case 'editor.editor.funky': + $this->enableModules(['filter', 'editor', 'ckeditor5']); + FilterFormat::create(['format' => 'funky', 'name' => 'Funky'])->save(); + Editor::create(['format' => 'funky', 'editor' => 'ckeditor5'])->save(); + break; + + case 'field.field.node.forum.comment_forum': + $this->enableModules(['field', 'node', 'comment', 'taxonomy', 'forum']); + $this->assertNull(FieldConfig::load('node.forum.comment_forum')); + // TRICKY: \Drupal\node\Entity\NodeType::$preview_mode uses + // DRUPAL_OPTIONAL, which is defined in system.module. + require_once 'core/modules/system/system.module'; + $this->installConfig(['forum']); + $this->assertNotNull(FieldConfig::load('node.forum.comment_forum')); + break; + } + + /** @var \Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager */ + $typed_config_manager = \Drupal::service('config.typed'); + $mapping = $typed_config_manager->get($config_name); + if ($property_path) { + $mapping = $mapping->get($property_path); + } + + assert($mapping instanceof Mapping); + $expected_required_keys = array_values(array_diff($expected_valid_keys, $expected_optional_keys)); + $this->assertSame($expected_valid_keys, $mapping->getValidKeys()); + $this->assertSame($expected_required_keys, $mapping->getRequiredKeys()); + $this->assertSame($expected_dynamically_valid_keys, $mapping->getDynamicallyValidKeys()); + $this->assertSame($expected_optional_keys, $mapping->getOptionalKeys()); + } + + /** + * Provides test cases for all kinds of i) dynamic typing, ii) optional keys. + * + * @see https://www.drupal.org/files/ConfigSchemaCheatSheet2.0.pdf + * + * @return \Generator + */ + public function providerMappingInterpretation(): \Generator { + $available_block_settings_types = [ + 'block.settings.field_block:*:*:*' => [ + 'formatter', + ], + 'block.settings.extra_field_block:*:*:*' => [ + 'formatter', + ], + 'block.settings.system_branding_block' => [ + 'use_site_logo', + 'use_site_name', + 'use_site_slogan', + ], + 'block.settings.system_menu_block:*' => [ + 'level', + 'depth', + 'expand_all_items', + ], + 'block.settings.local_tasks_block' => [ + 'primary', + 'secondary', + ], + ]; + + // A simple config often is just a single Mapping object. + yield 'No dynamic type: core.extension' => [ + 'core.extension', + NULL, + [ + // Keys inherited from `type: config_object`. + // @see core/config/schema/core.data_types.schema.yml + '_core', + 'langcode', + // Keys defined locally, in `type: core.extension`. + // @see core/config/schema/core.extension.schema.yml + 'module', + 'theme', + 'profile', + ], + ['_core'], + [], + ]; + + // Special case: deprecated is needed for deprecated config schema: + // - deprecated keys are treated as optional + // - if a deprecated property path is itself a mapping, then the keys inside + // are not optional + yield 'No dynamic type: config_schema_deprecated_test.settings' => [ + 'config_schema_deprecated_test.settings', + NULL, + [ + // Keys inherited from `type: config_object`. + // @see core/config/schema/core.data_types.schema.yml + '_core', + 'langcode', + // Keys defined locally, in `type: config_schema_deprecated_test.settings`. + // @see core/modules/config/tests/config_schema_deprecated_test/config/schema/config_schema_deprecated_test.schema.yml + 'complex_structure_deprecated', + ], + ['_core', 'complex_structure_deprecated'], + [], + ]; + yield 'No dynamic type: config_schema_deprecated_test.settings:complex_structure_deprecated' => [ + 'config_schema_deprecated_test.settings', + 'complex_structure_deprecated', + [ + // Keys defined locally, in `type: config_schema_deprecated_test.settings`. + // @see core/modules/config/tests/config_schema_deprecated_test/config/schema/config_schema_deprecated_test.schema.yml + 'type', + 'products', + ], + [], + [], + ]; + + // A config entity is always a Mapping at the top level, but most nesting is + // also using Mappings (unless the keys are free to be chosen, then a + // Sequence would be used). + yield 'No dynamic type: block.block.branding' => [ + 'block.block.branding', + NULL, + [ + // Keys inherited from `type: config_entity`. + // @see core/config/schema/core.data_types.schema.yml + 'uuid', + 'langcode', + 'status', + 'dependencies', + 'third_party_settings', + '_core', + // Keys defined locally, in `type: block.block.*`. + // @see core/modules/block/config/schema/block.schema.yml + 'id', + 'theme', + 'region', + 'weight', + 'provider', + 'plugin', + 'settings', + 'visibility', + ], + ['third_party_settings', '_core'], + [], + ]; + + // An example of nested Mapping objects in config entities. + yield 'No dynamic type: block.block.branding:dependencies' => [ + 'block.block.branding', + 'dependencies', + [ + // Keys inherited from `type: config_dependencies_base`. + // @see core/config/schema/core.data_types.schema.yml + 'config', + 'content', + 'module', + 'theme', + // Keys defined locally, in `type: config_dependencies`. + // @see core/config/schema/core.data_types.schema.yml + 'enforced', + ], + // All these keys are optional! + ['config', 'content', 'module', 'theme', 'enforced'], + [], + ]; + + // Three examples of `[%parent]`-based dynamic typing in config schema, and + // the consequences on what keys are considered valid: the first 2 depend + // on the block plugin being used using a single `%parent`, the third + // depends on the field plugin being used using a double `%parent`. + // See `type: block.block.*` which uses + // `type: block.settings.[%parent.plugin]`, and `type: field_config_base` + // which uses `type: field.value.[%parent.%parent.field_type]`. + yield 'Dynamic type with [%parent]: block.block.branding:settings' => [ + 'block.block.branding', + 'settings', + [ + // Keys inherited from `type: block.settings.*`, which in turn is + // inherited from `type: block_settings`. + // @see core/config/schema/core.data_types.schema.yml + 'id', + 'label', + 'label_display', + 'provider', + 'status', + 'info', + 'view_mode', + 'context_mapping', + // Keys defined locally, in `type: block.settings.system_branding_block`. + // @see core/modules/block/config/schema/block.schema.yml + ...$available_block_settings_types['block.settings.system_branding_block'], + ], + [], + $available_block_settings_types, + ]; + yield 'Dynamic type with [%parent]: block.block.local_tasks:settings' => [ + 'block.block.local_tasks', + 'settings', + [ + // Keys inherited from `type: block.settings.*`, which in turn is + // inherited from `type: block_settings`. + // @see core/config/schema/core.data_types.schema.yml + 'id', + 'label', + 'label_display', + 'provider', + 'status', + 'info', + 'view_mode', + 'context_mapping', + // Keys defined locally, in `type: block.settings.local_tasks_block`. + // @see core/modules/system/config/schema/system.schema.yml + ...$available_block_settings_types['block.settings.local_tasks_block'], + ], + [], + $available_block_settings_types, + ]; + yield 'Dynamic type with [%parent.%parent]: field.field.node.forum.comment_forum:default_value.0' => [ + 'field.field.node.forum.comment_forum', + 'default_value.0', + [ + // Keys defined locally, in `type: field.value.comment`. + // @see core/modules/comment/config/schema/comment.schema.yml + 'status', + 'cid', + 'last_comment_timestamp', + 'last_comment_name', + 'last_comment_uid', + 'comment_count', + ], + [], + [ + 'field.value.string' => ['value'], + 'field.value.string_long' => ['value'], + 'field.value.uri' => ['value'], + 'field.value.created' => ['value'], + 'field.value.changed' => ['value'], + 'field.value.entity_reference' => ['target_id', 'target_uuid'], + 'field.value.boolean' => ['value'], + 'field.value.email' => ['value'], + 'field.value.integer' => ['value'], + 'field.value.decimal' => ['value'], + 'field.value.float' => ['value'], + 'field.value.timestamp' => ['value'], + 'field.value.comment' => [ + 'status', + 'cid', + 'last_comment_timestamp', + 'last_comment_name', + 'last_comment_uid', + 'comment_count', + ], + ], + ]; + + // An example of `[childkey]`-based dynamic mapping typing in config schema, + // for a mapping inside a sequence: the `id` key-value pair in the mapping + // determines the type of the mapping. The key in the sequence whose value + // is the mapping is irrelevant, it can be arbitrarily chosen. + // See `type: block.block.*` which uses `type: condition.plugin.[id]`. + yield 'Dynamic type with [childkey]: block.block.positively_powered:visibility.I_CAN_CHOOSE_THIS' => [ + 'block.block.positively_powered', + 'visibility.I_CAN_CHOOSE_THIS', + [ + // Keys inherited from `type: condition.plugin`. + // @see core/config/schema/core.data_types.schema.yml + 'id', + 'negate', + 'uuid', + 'context_mapping', + // Keys defined locally, in `type: condition.plugin.response_status`. + // @see core/modules/system/config/schema/system.schema.yml + 'status_codes', + ], + [], + // Note the presence of `id`, `negate`, `uuid` and `context_mapping` here. + // That's because there is no `condition.plugin.*` type that specifies + // defaults. Each individual condition plugin has the freedom to deviate + // from this approach! + [ + 'condition.plugin.entity_bundle:*' => [ + 'id', + 'negate', + 'uuid', + 'context_mapping', + 'bundles', + ], + 'condition.plugin.request_path' => [ + 'id', + 'negate', + 'uuid', + 'context_mapping', + 'pages', + ], + 'condition.plugin.response_status' => [ + 'id', + 'negate', + 'uuid', + 'context_mapping', + 'status_codes', + ], + 'condition.plugin.current_theme' => [ + 'id', + 'negate', + 'uuid', + 'context_mapping', + 'theme', + ], + ], + ]; + // Same, but what if `type: condition.plugin.*` would have existed? + // @see core/modules/config/tests/config_schema_add_fallback_type_test/config/schema/config_schema_add_fallback_type_test.schema.yml + yield 'Dynamic type with [childkey]: block.block.positively_powered___alternate_reality_with_fallback_type___:visibility' => [ + 'block.block.positively_powered___alternate_reality_with_fallback_type___', + 'visibility.I_CAN_CHOOSE_THIS', + [ + // Keys inherited from `type: condition.plugin`. + // @see core/config/schema/core.data_types.schema.yml + 'id', + 'negate', + 'uuid', + 'context_mapping', + // Keys defined locally, in `type: condition.plugin.response_status`. + // @see core/modules/system/config/schema/system.schema.yml + 'status_codes', + ], + [], + // Note the ABSENCE of `id`, `negate`, `uuid` and `context_mapping` + // compared to the previous test case, because now the + // `condition.plugin.*` type does exist. + [ + 'condition.plugin.entity_bundle:*' => [ + 'bundles', + ], + 'condition.plugin.request_path' => [ + 'pages', + ], + 'condition.plugin.response_status' => [ + 'status_codes', + ], + 'condition.plugin.current_theme' => [ + 'theme', + ], + ], + ]; + + // An example of `[%key]`-based dynamic mapping typing in config schema: the + // key in the sequence determines the type of the mapping. Unlike the above + // `[childkey]` example, the key has meaning here. + // See `type: editor.settings.ckeditor5`, which uses + // `type: ckeditor5.plugin.[%key]`. + yield 'Dynamic type with [%key]: editor.editor.funky:settings.plugins.ckeditor5_heading' => [ + 'editor.editor.funky', + 'settings.plugins.ckeditor5_heading', + [ + // Keys defined locally, in `type: ckeditor5.plugin.ckeditor5_heading`. + // @see core/modules/ckeditor5/config/schema/ckeditor5.schema.yml + 'enabled_headings', + ], + [], + [ + 'ckeditor5.plugin.ckeditor5_language' => ['language_list'], + 'ckeditor5.plugin.ckeditor5_heading' => ['enabled_headings'], + 'ckeditor5.plugin.ckeditor5_imageResize' => ['allow_resize'], + 'ckeditor5.plugin.ckeditor5_sourceEditing' => ['allowed_tags'], + 'ckeditor5.plugin.ckeditor5_alignment' => ['enabled_alignments'], + 'ckeditor5.plugin.ckeditor5_list' => ['properties', 'multiBlock'], + 'ckeditor5.plugin.media_media' => ['allow_view_mode_override'], + 'ckeditor5.plugin.ckeditor5_codeBlock' => ['languages'], + 'ckeditor5.plugin.ckeditor5_style' => ['styles'], + ], + ]; + } + + /** + * @testWith [false, 42, "The mapping definition at `foobar` is invalid: its `invalid` key contains a integer. It must be an array."] + * [false, 10.2, "The mapping definition at `foobar` is invalid: its `invalid` key contains a double. It must be an array."] + * [false, "type", "The mapping definition at `foobar` is invalid: its `invalid` key contains a string. It must be an array."] + * [false, false, "The mapping definition at `foobar` is invalid: its `invalid` key contains a boolean. It must be an array."] + * [true, 42, "The mapping definition at `my_module.settings:foobar` is invalid: its `invalid` key contains a integer. It must be an array."] + * [true, 10.2, "The mapping definition at `my_module.settings:foobar` is invalid: its `invalid` key contains a double. It must be an array."] + * [true, "type", "The mapping definition at `my_module.settings:foobar` is invalid: its `invalid` key contains a string. It must be an array."] + * [true, false, "The mapping definition at `my_module.settings:foobar` is invalid: its `invalid` key contains a boolean. It must be an array."] + */ + public function testInvalidMappingKeyDefinition(bool $has_parent, mixed $invalid_key_definition, string $expected_message): void { + $definition = new MapDataDefinition([ + 'type' => 'mapping', + 'mapping' => [ + 'valid' => [ + 'type' => 'boolean', + 'label' => 'This is a valid key-value pair in this mapping', + ], + 'invalid' => $invalid_key_definition, + ], + ]); + $parent = NULL; + if ($has_parent) { + $parent = new Mapping( + new MapDataDefinition(['type' => 'mapping', 'mapping' => []]), + 'my_module.settings', + ); + } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage($expected_message); + new Mapping($definition, 'foobar', $parent); + } + + /** + * @testWith [true] + * [1] + * ["true"] + * [0] + * ["false"] + */ + public function testInvalidRequiredKeyFlag(mixed $required_key_flag_value): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The `requiredKey` flag must either be omitted or have `false` as the value.'); + new Mapping(new MapDataDefinition([ + 'type' => 'mapping', + 'mapping' => [ + 'something' => [ + 'requiredKey' => $required_key_flag_value, + ], + ], + ])); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php b/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php index adf5099d902d..ee0efaf155a9 100644 --- a/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php +++ b/core/tests/Drupal/KernelTests/Core/Config/ConfigSchemaTest.php @@ -70,6 +70,7 @@ public function testSchemaMapping() { $expected['class'] = Mapping::class; $expected['mapping']['langcode']['type'] = 'langcode'; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['mapping']['test_item'] = ['label' => 'Test item']; $expected['mapping']['test_list'] = ['label' => 'Test list']; $expected['type'] = 'config_schema_test.some_schema'; @@ -87,6 +88,7 @@ public function testSchemaMapping() { $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; $expected['unwrap_for_canonical_representation'] = TRUE; + $expected['requiredKey'] = TRUE; $this->assertEquals($expected, $definition, 'Automatic type detected for a scalar is undefined.'); $definition = $config->get('test_list')->getDataDefinition()->toArray(); $expected = []; @@ -95,6 +97,7 @@ public function testSchemaMapping() { $expected['type'] = 'undefined'; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; $expected['unwrap_for_canonical_representation'] = TRUE; + $expected['requiredKey'] = TRUE; $this->assertEquals($expected, $definition, 'Automatic type detected for a list is undefined.'); $definition = $config->get('test_no_schema')->getDataDefinition()->toArray(); $expected = []; @@ -118,6 +121,7 @@ public function testSchemaMapping() { 'type' => 'langcode', ]; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['type'] = 'system.maintenance'; $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; $expected['unwrap_for_canonical_representation'] = TRUE; @@ -134,6 +138,7 @@ public function testSchemaMapping() { 'type' => 'langcode', ]; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['mapping']['label'] = [ 'label' => 'Label', 'type' => 'label', @@ -164,6 +169,7 @@ public function testSchemaMapping() { $expected['class'] = Ignore::class; $expected['definition_class'] = '\Drupal\Core\TypedData\DataDefinition'; $expected['unwrap_for_canonical_representation'] = TRUE; + $expected['requiredKey'] = TRUE; $this->assertEquals($expected, $definition); $definition = \Drupal::service('config.typed')->get('config_schema_test.ignore')->get('indescribable')->getDataDefinition()->toArray(); $expected['label'] = 'Indescribable'; @@ -195,7 +201,9 @@ public function testSchemaMapping() { $expected['mapping']['third_party_settings']['type'] = 'sequence'; $expected['mapping']['third_party_settings']['label'] = 'Third party settings'; $expected['mapping']['third_party_settings']['sequence']['type'] = '[%parent.%parent.%type].third_party.[%key]'; + $expected['mapping']['third_party_settings']['requiredKey'] = FALSE; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['type'] = 'image.style.*'; $expected['constraints'] = ['ValidKeys' => '<infer>']; @@ -225,6 +233,10 @@ public function testSchemaMapping() { $definition = $effects->get('bddf0d06-42f9-4c75-a700-a33cafa25ea0')->get('data')->getDataDefinition()->toArray(); // This should be the schema for image.effect.image_scale, reuse previous one. $expected['type'] = 'image.effect.image_scale'; + $expected['mapping']['width']['requiredKey'] = TRUE; + $expected['mapping']['height']['requiredKey'] = TRUE; + $expected['mapping']['upscale']['requiredKey'] = TRUE; + $expected['requiredKey'] = TRUE; $this->assertEquals($expected, $definition, 'Retrieved the right metadata for the first effect of image.style.medium'); @@ -237,8 +249,8 @@ public function testSchemaMapping() { $expected['definition_class'] = '\Drupal\Core\TypedData\MapDataDefinition'; $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping'] = [ - 'integer' => ['type' => 'integer'], - 'string' => ['type' => 'string'], + 'integer' => ['type' => 'integer', 'requiredKey' => TRUE], + 'string' => ['type' => 'string', 'requiredKey' => TRUE], ]; $expected['constraints'] = ['ValidKeys' => '<infer>']; $this->assertEquals($expected, $definition, 'Retrieved the right metadata for config_test.dynamic.third_party:third_party_settings.config_schema_test'); @@ -251,6 +263,7 @@ public function testSchemaMapping() { $expected['class'] = Mapping::class; $expected['mapping']['langcode']['type'] = 'langcode'; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['mapping']['test_id']['type'] = 'string'; $expected['mapping']['test_id']['label'] = 'ID'; $expected['mapping']['test_description']['type'] = 'text'; @@ -282,6 +295,7 @@ public function testSchemaMappingWithParents() { 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', 'unwrap_for_canonical_representation' => TRUE, + 'requiredKey' => TRUE, ]; $this->assertEquals($expected, $definition); @@ -294,6 +308,7 @@ public function testSchemaMappingWithParents() { 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', 'unwrap_for_canonical_representation' => TRUE, + 'requiredKey' => TRUE, ]; $this->assertEquals($expected, $definition); @@ -306,6 +321,7 @@ public function testSchemaMappingWithParents() { 'class' => StringData::class, 'definition_class' => '\Drupal\Core\TypedData\DataDefinition', 'unwrap_for_canonical_representation' => TRUE, + 'requiredKey' => TRUE, ]; $this->assertEquals($expected, $definition); } @@ -525,6 +541,7 @@ public function testSchemaFallback() { $expected['unwrap_for_canonical_representation'] = TRUE; $expected['mapping']['langcode']['type'] = 'langcode'; $expected['mapping']['_core']['type'] = '_core_config_info'; + $expected['mapping']['_core']['requiredKey'] = FALSE; $expected['mapping']['test_id']['type'] = 'string'; $expected['mapping']['test_id']['label'] = 'ID'; $expected['mapping']['test_description']['type'] = 'text'; @@ -680,6 +697,12 @@ public function testConfigSaveWithWrappingSchemaDoubleBrackets() { \Drupal::configFactory()->getEditable('wrapping.config_schema_test.double_brackets') ->setData($untyped_values) ->save(); + // TRICKY: https://www.drupal.org/project/drupal/issues/2663410 introduced a + // bug that made TypedConfigManager sensitive to cache pollution. Saving + // config triggers validation, which in turn triggers that cache pollution + // bug. This is a work-around. + // @todo Remove in https://www.drupal.org/project/drupal/issues/3400181 + \Drupal::service('config.typed')->clearCachedDefinitions(); $this->assertSame($typed_values, \Drupal::config('wrapping.config_schema_test.double_brackets')->get()); $tests = \Drupal::service('config.typed')->get('wrapping.config_schema_test.double_brackets')->get('tests')->getElements(); @@ -713,6 +736,13 @@ public function testConfigSaveWithWrappingSchemaDoubleBrackets() { \Drupal::configFactory()->getEditable('wrapping.config_schema_test.double_brackets') ->setData($untyped_values) ->save(); + // TRICKY: https://www.drupal.org/project/drupal/issues/2663410 introduced a + // bug that made TypedConfigManager sensitive to cache pollution. Saving + // config in a test triggers the schema checking and validation logic from + // \Drupal\Core\Config\Development\ConfigSchemaChecker , which in turn + // triggers that cache pollution bug. This is a work-around. + // @todo Remove in https://www.drupal.org/project/drupal/issues/3400181 + \Drupal::service('config.typed')->clearCachedDefinitions(); $this->assertSame($typed_values, \Drupal::config('wrapping.config_schema_test.double_brackets')->get()); $tests = \Drupal::service('config.typed')->get('wrapping.config_schema_test.double_brackets')->get('tests')->getElements(); @@ -739,6 +769,13 @@ public function testConfigSaveWithWrappingSchemaDoubleBrackets() { \Drupal::configFactory()->getEditable('wrapping.config_schema_test.double_brackets') ->setData($typed_values) ->save(); + // TRICKY: https://www.drupal.org/project/drupal/issues/2663410 introduced a + // bug that made TypedConfigManager sensitive to cache pollution. Saving + // config in a test triggers the schema checking and validation logic from + // \Drupal\Core\Config\Development\ConfigSchemaChecker , which in turn + // triggers that cache pollution bug. This is a work-around. + // @todo Remove in https://www.drupal.org/project/drupal/issues/3400181 + \Drupal::service('config.typed')->clearCachedDefinitions(); $tests = \Drupal::service('config.typed')->get('wrapping.config_schema_test.double_brackets')->get('tests')->getElements(); $definition = $tests[0]->getDataDefinition()->toArray(); $this->assertEquals('wrapping.test.double_brackets.*||test.double_brackets.cat.dog', $definition['type']); @@ -759,12 +796,22 @@ public function testConfigSaveWithWrappingSchemaDoubleBrackets() { \Drupal::configFactory()->getEditable('wrapping.config_schema_test.other_double_brackets') ->setData($typed_values) ->save(); + // TRICKY: https://www.drupal.org/project/drupal/issues/2663410 introduced a + // bug that made TypedConfigManager sensitive to cache pollution. Saving + // config in a test triggers the schema checking and validation logic from + // \Drupal\Core\Config\Development\ConfigSchemaChecker , which in turn + // triggers that cache pollution bug. This is a work-around. + // @todo Remove in https://www.drupal.org/project/drupal/issues/3400181 + \Drupal::service('config.typed')->clearCachedDefinitions(); $tests = \Drupal::service('config.typed')->get('wrapping.config_schema_test.other_double_brackets')->get('tests')->getElements(); $definition = $tests[0]->getDataDefinition()->toArray(); // Check that definition type is a merge of the expected types. $this->assertEquals('wrapping.test.other_double_brackets.*||test.double_brackets.cat:*.*', $definition['type']); // Check that breed was inherited from parent definition. - $this->assertEquals(['type' => 'string'], $definition['mapping']['breed']); + $this->assertEquals([ + 'type' => 'string', + 'requiredKey' => TRUE, + ], $definition['mapping']['breed']); } } -- GitLab