From b03d9ab8014ae7539fda694b0cb0e4d3c69403ba Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Fri, 18 Oct 2024 09:30:15 +0100
Subject: [PATCH] Issue #3464550 by phenaproxima, a.dmitriiev, b_sharpe,
 alexpott: Create config action which can create an entity for every bundle of
 another entity type

---
 .../ConfigAction/CreateForEachBundle.php      | 138 +++++++++++++++++
 .../Deriver/CreateForEachBundleDeriver.php    |  56 +++++++
 .../Plugin/ConfigAction/EntityCreate.php      |   4 +-
 .../Core/Recipe/WildcardConfigActionsTest.php | 146 +++++++++++++++++-
 4 files changed, 341 insertions(+), 3 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/CreateForEachBundle.php
 create mode 100644 core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/CreateForEachBundleDeriver.php

diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/CreateForEachBundle.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/CreateForEachBundle.php
new file mode 100644
index 000000000000..08c4cfc60933
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/CreateForEachBundle.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\Config\Action\Attribute\ConfigAction;
+use Drupal\Core\Config\Action\ConfigActionManager;
+use Drupal\Core\Config\Action\ConfigActionPluginInterface;
+use Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver\CreateForEachBundleDeriver;
+use Drupal\Core\Config\ConfigManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Creates config entities for each bundle of a particular entity type.
+ *
+ * An example of using this in a recipe's config actions would be:
+ * @code
+ * node.type.*:
+ *   createForEach:
+ *     language.content_settings.node.%bundle:
+ *       target_entity_type_id: node
+ *       target_bundle: %bundle
+ *     image.style.node_%bundle_big:
+ *       label: 'Big images for %label content'
+ * @endcode
+ * This will create two entities for each existing content type: a content
+ * language settings entity, and an image style. For example, for a content type
+ * called `blog`, this will create `language.content_settings.node.blog` and
+ * `image.style.node_blog_big`, with the given values. The `%bundle` and
+ * `%label` placeholders will be replaced with the ID and label of the content
+ * type, respectively.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+#[ConfigAction(
+  id: 'create_for_each_bundle',
+  admin_label: new TranslatableMarkup('Create entities for each bundle of an entity type'),
+  deriver: CreateForEachBundleDeriver::class,
+)]
+final class CreateForEachBundle implements ConfigActionPluginInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * The placeholder which is replaced with the ID of the current bundle.
+   *
+   * @var string
+   */
+  private const BUNDLE_PLACEHOLDER = '%bundle';
+
+  /**
+   * The placeholder which is replaced with the label of the current bundle.
+   *
+   * @var string
+   */
+  private const LABEL_PLACEHOLDER = '%label';
+
+  public function __construct(
+    private readonly ConfigManagerInterface $configManager,
+    private readonly string $createAction,
+    private readonly ConfigActionManager $configActionManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
+    // If there are no bundle entity types, this plugin should not be usable.
+    if (empty($plugin_definition['entity_types'])) {
+      throw new InvalidPluginDefinitionException($plugin_id, "The $plugin_id config action must be restricted to entity types that are bundles of another entity type.");
+    }
+
+    return new static(
+      $container->get(ConfigManagerInterface::class),
+      $plugin_definition['create_action'],
+      $container->get('plugin.manager.config_action'),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(string $configName, mixed $value): void {
+    assert(is_array($value));
+
+    $bundle = $this->configManager->loadConfigEntityByName($configName);
+    assert(is_object($bundle));
+    $value = static::replacePlaceholders($value, [
+      static::BUNDLE_PLACEHOLDER => $bundle->id(),
+      static::LABEL_PLACEHOLDER => $bundle->label(),
+    ]);
+
+    foreach ($value as $name => $values) {
+      // Invoke the actual create action via the config action manager, so that
+      // the created entity will be validated.
+      $this->configActionManager->applyAction('entity_create:' . $this->createAction, $name, $values);
+    }
+  }
+
+  /**
+   * Replaces placeholders recursively.
+   *
+   * @param mixed $data
+   *   The data to process. If this is an array, it'll be processed recursively.
+   * @param array $replacements
+   *   An array whose keys are the placeholders to replace in the data, and
+   *   whose values are the the replacements. Normally this will only mention
+   *   the `%bundle` and `%label` placeholders. If $data is an array, the only
+   *   placeholder that is replaced in the array's keys is `%bundle`.
+   *
+   * @return mixed
+   *   The given $data, with the `%bundle` and `%label` placeholders replaced.
+   */
+  private static function replacePlaceholders(mixed $data, array $replacements): mixed {
+    assert(array_key_exists(static::BUNDLE_PLACEHOLDER, $replacements));
+
+    if (is_string($data)) {
+      $data = str_replace(array_keys($replacements), $replacements, $data);
+    }
+    elseif (is_array($data)) {
+      foreach ($data as $old_key => $value) {
+        $value = static::replacePlaceholders($value, $replacements);
+
+        // Only replace the `%bundle` placeholder in array keys.
+        $new_key = str_replace(static::BUNDLE_PLACEHOLDER, $replacements[static::BUNDLE_PLACEHOLDER], $old_key);
+        if ($old_key !== $new_key) {
+          unset($data[$old_key]);
+        }
+        $data[$new_key] = $value;
+      }
+    }
+    return $data;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/CreateForEachBundleDeriver.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/CreateForEachBundleDeriver.php
new file mode 100644
index 000000000000..920a727d7051
--- /dev/null
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/Deriver/CreateForEachBundleDeriver.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Config\Action\Plugin\ConfigAction\Deriver;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Generates derivatives for the create_for_each_bundle config action.
+ *
+ * @internal
+ *   This API is experimental.
+ */
+final class CreateForEachBundleDeriver extends DeriverBase implements ContainerDeriverInterface {
+
+  public function __construct(
+    private readonly EntityTypeManagerInterface $entityTypeManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id): static {
+    return new static(
+      $container->get(EntityTypeManagerInterface::class),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition): array {
+    // The action should only be available for entity types that are bundles of
+    // another entity type, such as node types, media types, taxonomy
+    // vocabularies, and so forth.
+    $bundle_entity_types = array_filter(
+      $this->entityTypeManager->getDefinitions(),
+      fn (EntityTypeInterface $entity_type) => is_string($entity_type->getBundleOf()),
+    );
+    $base_plugin_definition['entity_types'] = array_keys($bundle_entity_types);
+
+    $this->derivatives['createForEachIfNotExists'] = $base_plugin_definition + [
+      'create_action' => 'createIfNotExists',
+    ];
+    $this->derivatives['createForEach'] = $base_plugin_definition + [
+      'create_action' => 'create',
+    ];
+    return $this->derivatives;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
index f1e5c54f4d30..cc1dd909e805 100644
--- a/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
+++ b/core/lib/Drupal/Core/Config/Action/Plugin/ConfigAction/EntityCreate.php
@@ -70,7 +70,9 @@ public function apply(string $configName, mixed $value): void {
     $id = substr($configName, strlen($entity_type->getConfigPrefix()) + 1);
     $entity_type_manager
       ->getStorage($entity_type->id())
-      ->create($value + ['id' => $id])
+      ->create($value + [
+        $entity_type->getKey('id') => $id,
+      ])
       ->save();
   }
 
diff --git a/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
index 0b9600aa6af6..8421eb1bce58 100644
--- a/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Recipe/WildcardConfigActionsTest.php
@@ -4,19 +4,27 @@
 
 namespace Drupal\KernelTests\Core\Recipe;
 
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\Core\Config\Action\ConfigActionException;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Recipe\InvalidConfigException;
 use Drupal\Core\Recipe\RecipeRunner;
 use Drupal\entity_test\Entity\EntityTestBundle;
 use Drupal\field\Entity\FieldConfig;
 use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\FunctionalTests\Core\Recipe\RecipeTestTrait;
+use Drupal\image\Entity\ImageStyle;
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ContentLanguageSettings;
 use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Symfony\Component\Validator\Constraints\NotNull;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
 /**
  * Tests config actions targeting multiple entities using wildcards.
  *
+ * @covers \Drupal\Core\Config\Action\Plugin\ConfigAction\CreateForEachBundle
  * @group Recipe
  */
 class WildcardConfigActionsTest extends KernelTestBase {
@@ -43,8 +51,8 @@ protected function setUp(): void {
     parent::setUp();
     $this->installConfig('node');
 
-    $this->createContentType(['type' => 'one']);
-    $this->createContentType(['type' => 'two']);
+    $this->createContentType(['type' => 'one', 'name' => 'Type A']);
+    $this->createContentType(['type' => 'two', 'name' => 'Type B']);
 
     EntityTestBundle::create(['id' => 'one'])->save();
     EntityTestBundle::create(['id' => 'two'])->save();
@@ -132,4 +140,138 @@ public function testInvalidExpression(string $expression, string $expected_excep
     RecipeRunner::processRecipe($recipe);
   }
 
+  /**
+   * Tests that the createForEach action works as expected in normal conditions.
+   */
+  public function testCreateForEach(): void {
+    $this->enableModules(['image', 'language']);
+
+    /** @var \Drupal\Core\Config\Action\ConfigActionManager $manager */
+    $manager = $this->container->get('plugin.manager.config_action');
+    $manager->applyAction('createForEach', 'node.type.*', [
+      'language.content_settings.node.%bundle' => [
+        'target_entity_type_id' => 'node',
+        'target_bundle' => '%bundle',
+      ],
+    ]);
+    $this->assertIsObject(ContentLanguageSettings::load('node.one'));
+    $this->assertIsObject(ContentLanguageSettings::load('node.two'));
+  }
+
+  /**
+   * Tests that the createForEach action validates the config it creates.
+   */
+  public function testCreateForEachValidatesCreatedEntities(): void {
+    $this->enableModules(['image']);
+
+    // To prove that the validation runs, we need to disable strict schema
+    // checking in this test. We need to explicitly unsubscribe it from events
+    // because by this point in the test it has been fully wired up into the
+    // container and can't be changed.
+    $schema_checker = $this->container->get('testing.config_schema_checker');
+    $this->container->get(EventDispatcherInterface::class)
+      ->removeSubscriber($schema_checker);
+
+    try {
+      $this->container->get('plugin.manager.config_action')
+        ->applyAction('createForEach', 'node.type.*', [
+          'image.style.node__%bundle' => [],
+        ]);
+      $this->fail('Expected an exception to be thrown but it was not.');
+    }
+    catch (InvalidConfigException $e) {
+      $this->assertSame('image.style.node__one', $e->data->getName());
+      $this->assertCount(1, $e->violations);
+      $this->assertSame('label', $e->violations[0]->getPropertyPath());
+      $this->assertSame(NotNull::IS_NULL_ERROR, $e->violations[0]->getCode());
+    }
+  }
+
+  /**
+   * Tests using the `%label` placeholder with the createForEach action.
+   */
+  public function testCreateForEachWithLabel(): void {
+    $this->enableModules(['image']);
+
+    // We should be able to use the `%label` placeholder.
+    $this->container->get('plugin.manager.config_action')
+      ->applyAction('createForEach', 'node.type.*', [
+        'image.style.node_%bundle_big' => [
+          'label' => 'Big image for %label content',
+        ],
+      ]);
+    $this->assertSame('Big image for Type A content', ImageStyle::load('node_one_big')?->label());
+    $this->assertSame('Big image for Type B content', ImageStyle::load('node_two_big')?->label());
+  }
+
+  /**
+   * Tests that the createForEachIfNotExists action ignores existing config.
+   */
+  public function testCreateForEachIfNotExists(): void {
+    $this->enableModules(['language']);
+
+    ContentLanguageSettings::create([
+      'target_entity_type_id' => 'node',
+      'target_bundle' => 'one',
+    ])->save();
+
+    $this->container->get('plugin.manager.config_action')
+      ->applyAction('createForEachIfNotExists', 'node.type.*', [
+        'language.content_settings.node.%bundle' => [
+          'target_entity_type_id' => 'node',
+          'target_bundle' => '%bundle',
+        ],
+      ]);
+    $this->assertIsObject(ContentLanguageSettings::loadByEntityTypeBundle('node', 'two'));
+  }
+
+  /**
+   * Tests that the createForEach action errs on conflict with existing config.
+   */
+  public function testCreateForEachErrorsIfAlreadyExists(): void {
+    $this->enableModules(['language']);
+
+    ContentLanguageSettings::create([
+      'target_entity_type_id' => 'node',
+      'target_bundle' => 'one',
+    ])->save();
+
+    $this->expectExceptionMessage(ConfigActionException::class);
+    $this->expectExceptionMessage('Entity language.content_settings.node.one exists');
+    $this->container->get('plugin.manager.config_action')
+      ->applyAction('createForEach', 'node.type.*', [
+        'language.content_settings.node.%bundle' => [
+          'target_entity_type_id' => 'node',
+          'target_bundle' => '%bundle',
+        ],
+      ]);
+  }
+
+  /**
+   * Tests that the createForEach action only works on bundle entities.
+   */
+  public function testCreateForEachNotAvailableOnNonBundleEntities(): void {
+    $this->enableModules(['language']);
+
+    // We should not be able to use this action on entities that aren't
+    // themselves bundles of another entity type.
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "language_content_settings" entity does not support the "createForEach" config action.');
+    $this->container->get('plugin.manager.config_action')
+      ->applyAction('createForEach', 'language.content_settings.node.*', []);
+  }
+
+  /**
+   * Tests that the createForEach action requires bundle entity types to exist.
+   */
+  public function testCreateForEachErrorsIfNoBundleEntityTypesExist(): void {
+    $this->disableModules(['node', 'entity_test']);
+
+    $manager = $this->container->get('plugin.manager.config_action');
+    $manager->clearCachedDefinitions();
+    $this->expectException(InvalidPluginDefinitionException::class);
+    $this->expectExceptionMessage('The create_for_each_bundle:createForEach config action must be restricted to entity types that are bundles of another entity type.');
+    $manager->applyAction('create_for_each_bundle:createForEach', 'node.type.*', []);
+  }
+
 }
-- 
GitLab