From aae43b1854d0e4124adf20743ab792f1a2d03922 Mon Sep 17 00:00:00 2001
From: catch <6915-catch@users.noreply.drupalcode.org>
Date: Tue, 11 Feb 2025 14:39:44 +0000
Subject: [PATCH] Issue #3040556 by sakiland, taran2l, jhuhta, berdir,
 richgerdes, julien.sibi, ksenzee, aaronmchale, bojanz, nicxvan, hchonov,
 godotislate, joachim: It is not possible to react to an entity being
 duplicated

---
 .../Drupal/Core/Entity/ContentEntityBase.php  |  5 ++++
 core/lib/Drupal/Core/Entity/EntityBase.php    |  6 ++++
 core/lib/Drupal/Core/Entity/entity.api.php    | 30 +++++++++++++++++++
 .../entity_test/src/Hook/EntityTestHooks.php  | 21 +++++++++++++
 .../Core/Entity/EntityUUIDTest.php            | 13 +++++++-
 .../Entity/ConfigEntityBaseUnitTest.php       |  5 ++++
 6 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/core/lib/Drupal/Core/Entity/ContentEntityBase.php b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
index 3b658a7ed35e..039fdce703eb 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityBase.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
@@ -1217,6 +1217,11 @@ public function createDuplicate() {
       $duplicate->loadedRevisionId = NULL;
     }
 
+    // Modules might need to add or change the data initially held by the new
+    // entity object, for instance to fill-in default values.
+    \Drupal::moduleHandler()->invokeAll($this->getEntityTypeId() . '_duplicate', [$duplicate, $this]);
+    \Drupal::moduleHandler()->invokeAll('entity_duplicate', [$duplicate, $this]);
+
     return $duplicate;
   }
 
diff --git a/core/lib/Drupal/Core/Entity/EntityBase.php b/core/lib/Drupal/Core/Entity/EntityBase.php
index 129d3ddbfc99..ad048198d1fb 100644
--- a/core/lib/Drupal/Core/Entity/EntityBase.php
+++ b/core/lib/Drupal/Core/Entity/EntityBase.php
@@ -393,6 +393,12 @@ public function createDuplicate() {
     if ($entity_type->hasKey('uuid')) {
       $duplicate->{$entity_type->getKey('uuid')} = $this->uuidGenerator()->generate();
     }
+
+    // Modules might need to add or change the data initially held by the new
+    // entity object, for instance to fill-in default values.
+    \Drupal::moduleHandler()->invokeAll($this->getEntityTypeId() . '_duplicate', [$duplicate, $this]);
+    \Drupal::moduleHandler()->invokeAll('entity_duplicate', [$duplicate, $this]);
+
     return $duplicate;
   }
 
diff --git a/core/lib/Drupal/Core/Entity/entity.api.php b/core/lib/Drupal/Core/Entity/entity.api.php
index 82fea8aed03d..31d3061975c4 100644
--- a/core/lib/Drupal/Core/Entity/entity.api.php
+++ b/core/lib/Drupal/Core/Entity/entity.api.php
@@ -977,6 +977,36 @@ function hook_ENTITY_TYPE_create(\Drupal\Core\Entity\EntityInterface $entity) {
   \Drupal::logger('example')->info('ENTITY_TYPE created: @label', ['@label' => $entity->label()]);
 }
 
+/**
+ * Acts when duplicating an existing entity.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $duplicate
+ *   The duplicated entity object.
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The original entity object.
+ *
+ * @ingroup entity_crud
+ * @see hook_ENTITY_TYPE_duplicate()
+ */
+function hook_entity_duplicate(\Drupal\Core\Entity\EntityInterface $duplicate, \Drupal\Core\Entity\EntityInterface $entity): void {
+  \Drupal::logger('example')->info('Entity duplicated: @label', ['@label' => $entity->label()]);
+}
+
+/**
+ * Acts when duplicating an existing entity of a specific type.
+ *
+ * @param \Drupal\Core\Entity\EntityInterface $duplicate
+ *   The duplicated entity object.
+ * @param \Drupal\Core\Entity\EntityInterface $entity
+ *   The original entity object.
+ *
+ * @ingroup entity_crud
+ * @see hook_entity_duplicate()
+ */
+function hook_ENTITY_TYPE_duplicate(\Drupal\Core\Entity\EntityInterface $duplicate, \Drupal\Core\Entity\EntityInterface $entity): void {
+  \Drupal::logger('example')->info('ENTITY_TYPE duplicated: @label', ['@label' => $entity->label()]);
+}
+
 /**
  * Respond to entity revision creation.
  *
diff --git a/core/modules/system/tests/modules/entity_test/src/Hook/EntityTestHooks.php b/core/modules/system/tests/modules/entity_test/src/Hook/EntityTestHooks.php
index 268a12435ac3..d665a70186cd 100644
--- a/core/modules/system/tests/modules/entity_test/src/Hook/EntityTestHooks.php
+++ b/core/modules/system/tests/modules/entity_test/src/Hook/EntityTestHooks.php
@@ -22,6 +22,7 @@
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Hook\Attribute\Hook;
 use Drupal\entity_test\EntityTestHelper;
+use Drupal\entity_test\Entity\EntityTest;
 
 /**
  * Hook implementations for entity_test.
@@ -705,4 +706,24 @@ public function entityTestFormModeAlter(&$form_mode, EntityInterface $entity) :
     }
   }
 
+  /**
+   * Implements hook_entity_duplicate().
+   */
+  #[Hook('entity_duplicate')]
+  public function entityDuplicateAlter(EntityInterface $duplicate, EntityInterface $entity) : void {
+    if ($duplicate instanceof ContentEntityInterface && str_contains($duplicate->label(), 'UUID CRUD test entity') && $duplicate->hasField('name')) {
+      $duplicate->set('name', $duplicate->label() . ' duplicate');
+    }
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_duplicate().
+   */
+  #[Hook('entity_test_duplicate')]
+  public function entityTestDuplicate(EntityTest $duplicate, EntityTest $entity) : void {
+    if (str_contains($duplicate->label(), 'UUID CRUD test entity') && $duplicate->hasField('name')) {
+      $duplicate->set('name', 'prefix ' . $duplicate->label());
+    }
+  }
+
 }
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityUUIDTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityUUIDTest.php
index dee6c23dff8e..2713761dd2f4 100644
--- a/core/tests/Drupal/KernelTests/Core/Entity/EntityUUIDTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityUUIDTest.php
@@ -62,7 +62,7 @@ protected function assertCRUD(string $entity_type): void {
     // Verify that a new UUID is generated upon creating an entity.
     $entity = $this->container->get('entity_type.manager')
       ->getStorage($entity_type)
-      ->create(['name' => $this->randomMachineName()]);
+      ->create(['name' => 'UUID CRUD test entity']);
     $uuid = $entity->uuid();
     $this->assertNotEmpty($uuid);
 
@@ -109,6 +109,17 @@ protected function assertCRUD(string $entity_type): void {
           $this->assertNotEquals($entity->{$property}->getValue(), $entity_duplicate->{$property}->getValue());
           break;
 
+        case 'name':
+          // Assert alter hooks in \Drupal\entity_test\Hook\EntityTestHooks.
+          if ($entity_type === 'entity_test') {
+            $this->assertEquals('prefix UUID CRUD test entity duplicate', $entity_duplicate->label());
+          }
+          else {
+            $this->assertEquals('UUID CRUD test entity duplicate', $entity_duplicate->label());
+          }
+          $this->assertEquals('UUID CRUD test entity', $entity->label());
+          break;
+
         default:
           $this->assertEquals($entity->{$property}->getValue(), $entity_duplicate->{$property}->getValue());
       }
diff --git a/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityBaseUnitTest.php b/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityBaseUnitTest.php
index ccb5f6138dd8..f876e75ad33f 100644
--- a/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityBaseUnitTest.php
+++ b/core/tests/Drupal/Tests/Core/Config/Entity/ConfigEntityBaseUnitTest.php
@@ -507,6 +507,11 @@ public function testCreateDuplicate(): void {
     $this->assertNull($duplicate->getOriginalId());
     $this->assertNotEquals($this->entity->uuid(), $duplicate->uuid());
     $this->assertSame($new_uuid, $duplicate->uuid());
+
+    $this->moduleHandler->invokeAll($this->entityTypeId . '_duplicate', [$duplicate, $this->entity])
+      ->shouldHaveBeenCalled();
+    $this->moduleHandler->invokeAll('entity_duplicate', [$duplicate, $this->entity])
+      ->shouldHaveBeenCalled();
   }
 
   /**
-- 
GitLab