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