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