From 7857fc58e0f082ad2214f906d95e4c4e57d19d3b Mon Sep 17 00:00:00 2001
From: Mark Carver <mark.carver@me.com>
Date: Sat, 9 Dec 2017 22:43:56 -0600
Subject: [PATCH] Issue #2570593 by markcarver, larowlan: Allow entities to be
 subclassed more easily

---
 .../Core/Config/Entity/ConfigEntityStorage.php     |   3 +-
 .../Core/Entity/ContentEntityStorageBase.php       | 108 +++++++++++++++++++--
 core/lib/Drupal/Core/Entity/Entity.php             |   9 +-
 core/lib/Drupal/Core/Entity/EntityStorageBase.php  |  92 +++++++++++++-----
 .../Drupal/Core/Entity/EntityTypeRepository.php    |   6 +-
 .../Entity/KeyValueStore/KeyValueEntityStorage.php |   3 +-
 .../Core/Entity/Sql/SqlContentEntityStorage.php    |   3 +-
 .../entity_test_subclass.info.yml                  |   8 ++
 .../entity_test_subclass.module                    |  14 +++
 .../src/Entity/EntityTestSubclass.php              |  11 +++
 .../src/EntityTestSubclassStorage.php              |  22 +++++
 .../KernelTests/Core/Entity/EntitySubclassTest.php |  63 ++++++++++++
 12 files changed, 304 insertions(+), 38 deletions(-)
 create mode 100644 core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.info.yml
 create mode 100644 core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module
 create mode 100644 core/modules/system/tests/modules/entity_test_subclass/src/Entity/EntityTestSubclass.php
 create mode 100644 core/modules/system/tests/modules/entity_test_subclass/src/EntityTestSubclassStorage.php
 create mode 100644 core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php

diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php
index 723ba5316e..1d31fd58a1 100644
--- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php
+++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php
@@ -221,7 +221,8 @@ protected function doLoadMultiple(array $ids = NULL) {
   protected function doCreate(array $values) {
     // Set default language to current language if not provided.
     $values += [$this->langcodeKey => $this->languageManager->getCurrentLanguage()->getId()];
-    $entity = new $this->entityClass($values, $this->entityTypeId);
+    $entity_class = $this->getEntityClass();
+    $entity = new $entity_class($values, $this->entityTypeId);
 
     return $entity;
   }
diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
index a3bb20508f..d958205e2b 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
@@ -51,6 +51,45 @@ public function __construct(EntityTypeInterface $entity_type, EntityManagerInter
     $this->cacheBackend = $cache;
   }
 
+  /**
+   * A list of bundle classes.
+   *
+   * @return array
+   *   An associative array containing bundle class names, where the key is the
+   *   bundle machine name.
+   */
+  public static function bundleClasses() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function create(array $values = []) {
+    // In some cases the entity bundle may be provided by the entity class'
+    // ::preCreate method. If a bundle is truly required an exception will be
+    // thrown in the ::doCreate method so there's no need to throw one here.
+    $bundle = $this->getBundleFromValues($values, FALSE);
+    $entity_class = $this->getEntityClass($bundle);
+    $entity_class::preCreate($this, $values);
+
+    // Assign a new UUID if there is none yet.
+    if ($this->uuidKey && $this->uuidService && !isset($values[$this->uuidKey])) {
+      $values[$this->uuidKey] = $this->uuidService->generate();
+    }
+
+    $entity = $this->doCreate($values);
+    $entity->enforceIsNew();
+
+    $entity->postCreate($this);
+
+    // Modules might need to add or change the data initially held by the new
+    // entity object, for instance to fill-in default values.
+    $this->invokeHook('create', $entity);
+
+    return $entity;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -66,17 +105,72 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
    * {@inheritdoc}
    */
   protected function doCreate(array $values) {
-    // We have to determine the bundle first.
-    $bundle = FALSE;
+    $bundle = $this->getBundleFromValues($values);
+    $entity_class = $this->getEntityClass($bundle);
+    $entity = new $entity_class([], $this->entityTypeId, $bundle);
+    $this->initFieldValues($entity, $values);
+    return $entity;
+  }
+
+  /**
+   * Retrieves the bundle name for a provided class name.
+   *
+   * @param string $class_name
+   *   The class name to check.
+   *
+   * @return string|null
+   *   The bundle name of the class provided or NULL if unable to determine the
+   *   bundle from the provided class.
+   */
+  public function getBundleFromClass($class_name) {
+    $bundle_classes = array_flip(static::bundleClasses());
+    return isset($bundle_classes[$class_name]) ? $bundle_classes[$class_name] : NULL;
+  }
+
+  /**
+   * Retrieves the bundle from an array of values.
+   *
+   * @param array $values
+   *   An array of values to set, keyed by property name.
+   * @param bool $throw_exception
+   *   Flag indicating whether to throw an exception if corresponding bundle
+   *   cannot be found and is expected.
+   *
+   * @return string|null
+   *   The bundle or NULL if not set.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException when a corresponding
+   *   bundle cannot be found and is expected.
+   */
+  public function getBundleFromValues(array $values, $throw_exception = TRUE) {
+    $bundle = NULL;
     if ($this->bundleKey) {
-      if (!isset($values[$this->bundleKey])) {
+      if (isset($values[$this->bundleKey])) {
+        $bundle = $values[$this->bundleKey];
+      }
+      elseif ($throw_exception) {
         throw new EntityStorageException('Missing bundle for entity type ' . $this->entityTypeId);
       }
-      $bundle = $values[$this->bundleKey];
     }
-    $entity = new $this->entityClass([], $this->entityTypeId, $bundle);
-    $this->initFieldValues($entity, $values);
-    return $entity;
+    return $bundle;
+  }
+
+  /**
+   * Retrieves the entity bundle key.
+   *
+   * @return bool|string
+   *   The entity bundle key.
+   */
+  public function getBundleKey() {
+    return $this->bundleKey;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntityClass($bundle = NULL) {
+    $bundle_classes = static::bundleClasses();
+    return isset($bundle_classes[$bundle]) ? $bundle_classes[$bundle] : parent::getEntityClass($bundle);
   }
 
   /**
diff --git a/core/lib/Drupal/Core/Entity/Entity.php b/core/lib/Drupal/Core/Entity/Entity.php
index 4fe37866f1..d31572e1bf 100644
--- a/core/lib/Drupal/Core/Entity/Entity.php
+++ b/core/lib/Drupal/Core/Entity/Entity.php
@@ -540,7 +540,14 @@ public static function loadMultiple(array $ids = NULL) {
   public static function create(array $values = []) {
     $entity_type_repository = \Drupal::service('entity_type.repository');
     $entity_type_manager = \Drupal::entityTypeManager();
-    $storage = $entity_type_manager->getStorage($entity_type_repository->getEntityTypeFromClass(get_called_class()));
+    $class_name = get_called_class();
+    $storage = $entity_type_manager->getStorage($entity_type_repository->getEntityTypeFromClass($class_name));
+
+    // Always explicitly specify the bundle for a subclassed entity.
+    if ($storage instanceof ContentEntityStorageBase && ($bundle = $storage->getBundleFromClass($class_name))) {
+      $values[$storage->getBundleKey()] = $bundle;
+    }
+
     return $storage->create($values);
   }
 
diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
index e5bf880aef..469366eff0 100644
--- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php
+++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php
@@ -87,6 +87,20 @@ public function __construct(EntityTypeInterface $entity_type) {
     $this->entityClass = $this->entityType->getClass();
   }
 
+  /**
+   * Retrieves the class name used to create the entity.
+   *
+   * @param string $bundle
+   *   A specific entity type bundle identifier. Note: depending on how the
+   *   entity is configured, a bundle may not be provided.
+   *
+   * @return string
+   *   A class name.
+   */
+  protected function getEntityClass($bundle = NULL) {
+    return $this->entityClass;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -173,7 +187,7 @@ protected function invokeHook($hook, EntityInterface $entity) {
    * {@inheritdoc}
    */
   public function create(array $values = []) {
-    $entity_class = $this->entityClass;
+    $entity_class = $this->getEntityClass();
     $entity_class::preCreate($this, $values);
 
     // Assign a new UUID if there is none yet.
@@ -202,7 +216,8 @@ public function create(array $values = []) {
    * @return \Drupal\Core\Entity\EntityInterface
    */
   protected function doCreate(array $values) {
-    return new $this->entityClass($values, $this->entityTypeId);
+    $entity_class = $this->getEntityClass();
+    return new $entity_class($values, $this->entityTypeId);
   }
 
   /**
@@ -293,17 +308,31 @@ public function loadMultiple(array $ids = NULL) {
    *   Associative array of query results, keyed on the entity ID.
    */
   protected function postLoad(array &$entities) {
-    $entity_class = $this->entityClass;
-    $entity_class::postLoad($this, $entities);
-    // Call hook_entity_load().
-    foreach ($this->moduleHandler()->getImplementations('entity_load') as $module) {
-      $function = $module . '_entity_load';
-      $function($entities, $this->entityTypeId);
+    $entity_classes = [];
+
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    foreach ($entities as $id => $entity) {
+      $entity_class = get_class($entity);
+      if (!isset($entity_classes[$entity_class])) {
+        $entity_classes[$entity_class] = [];
+      }
+      $entity_classes[$entity_class][$id] = $entity;
     }
-    // Call hook_TYPE_load().
-    foreach ($this->moduleHandler()->getImplementations($this->entityTypeId . '_load') as $module) {
-      $function = $module . '_' . $this->entityTypeId . '_load';
-      $function($entities);
+
+    foreach ($entity_classes as $entity_class => &$items) {
+      $entity_class::postLoad($this, $entities);
+
+      // Call hook_entity_load().
+      foreach ($this->moduleHandler()->getImplementations('entity_load') as $module) {
+        $function = $module . '_entity_load';
+        $function($items, $this->entityTypeId);
+      }
+
+      // Call hook_TYPE_load().
+      foreach ($this->moduleHandler()->getImplementations($this->entityTypeId . '_load') as $module) {
+        $function = $module . '_' . $this->entityTypeId . '_load';
+        $function($items);
+      }
     }
   }
 
@@ -319,7 +348,8 @@ protected function postLoad(array &$entities) {
   protected function mapFromStorageRecords(array $records) {
     $entities = [];
     foreach ($records as $record) {
-      $entity = new $this->entityClass($record, $this->entityTypeId);
+      $entity_class = $this->getEntityClass();
+      $entity = new $entity_class($record, $this->entityTypeId);
       $entities[$entity->id()] = $entity;
     }
     return $entities;
@@ -352,21 +382,33 @@ public function delete(array $entities) {
       $keyed_entities[$entity->id()] = $entity;
     }
 
-    // Allow code to run before deleting.
-    $entity_class = $this->entityClass;
-    $entity_class::preDelete($this, $keyed_entities);
-    foreach ($keyed_entities as $entity) {
-      $this->invokeHook('predelete', $entity);
+    $entity_classes = [];
+
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    foreach ($keyed_entities as $id => $entity) {
+      $entity_class = get_class($entity);
+      if (!isset($entity_classes[$entity_class])) {
+        $entity_classes[$entity_class] = [];
+      }
+      $entity_classes[$entity_class][$id] = $entity;
     }
 
-    // Perform the delete and reset the static cache for the deleted entities.
-    $this->doDelete($keyed_entities);
-    $this->resetCache(array_keys($keyed_entities));
+    // Allow code to run before deleting.
+    foreach ($entity_classes as $entity_class => &$items) {
+      $entity_class::preDelete($this, $items);
+      foreach ($items as $entity) {
+        $this->invokeHook('predelete', $entity);
+      }
 
-    // Allow code to run after deleting.
-    $entity_class::postDelete($this, $keyed_entities);
-    foreach ($keyed_entities as $entity) {
-      $this->invokeHook('delete', $entity);
+      // Perform the delete and reset the static cache for the deleted entities.
+      $this->doDelete($items);
+      $this->resetCache(array_keys($items));
+
+      // Allow code to run after deleting.
+      $entity_class::postDelete($this, $items);
+      foreach ($items as $entity) {
+        $this->invokeHook('delete', $entity);
+      }
     }
   }
 
diff --git a/core/lib/Drupal/Core/Entity/EntityTypeRepository.php b/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
index d89b75e3ff..0c141a4c36 100644
--- a/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
+++ b/core/lib/Drupal/Core/Entity/EntityTypeRepository.php
@@ -81,9 +81,11 @@ public function getEntityTypeFromClass($class_name) {
     $same_class = 0;
     $entity_type_id = NULL;
     foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
-      if ($entity_type->getOriginalClass() == $class_name  || $entity_type->getClass() == $class_name) {
+      $class_found = $class_name === $entity_type->getClass() || $class_name === $entity_type->getOriginalClass();
+      $subclass_found = !$class_found && is_subclass_of($class_name, $entity_type->getClass()) || is_subclass_of($class_name, $entity_type->getOriginalClass());
+      if ($class_found || $subclass_found) {
         $entity_type_id = $entity_type->id();
-        if ($same_class++) {
+        if (!$subclass_found && $same_class++) {
           throw new AmbiguousEntityClassException($class_name);
         }
       }
diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php
index cd2f26efbd..d2ec233a1d 100644
--- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php
+++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php
@@ -89,7 +89,8 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
   public function doCreate(array $values = []) {
     // Set default language to site default if not provided.
     $values += [$this->getEntityType()->getKey('langcode') => $this->languageManager->getDefaultLanguage()->getId()];
-    $entity = new $this->entityClass($values, $this->entityTypeId);
+    $entity_class = $this->getEntityClass();
+    $entity = new $entity_class($values, $this->entityTypeId);
 
     // @todo This is handled by ContentEntityStorageBase, which assumes
     //   FieldableEntityInterface. The current approach in
diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
index 29b8a9aa95..3fa57c0a56 100644
--- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
+++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
@@ -514,7 +514,8 @@ protected function mapFromStorageRecords(array $records, $load_from_revision = F
     foreach ($values as $id => $entity_values) {
       $bundle = $this->bundleKey ? $entity_values[$this->bundleKey][LanguageInterface::LANGCODE_DEFAULT] : FALSE;
       // Turn the record into an entity class.
-      $entities[$id] = new $this->entityClass($entity_values, $this->entityTypeId, $bundle, array_keys($translations[$id]));
+      $entity_class = $this->getEntityClass($bundle);
+      $entities[$id] = new $entity_class($entity_values, $this->entityTypeId, $bundle, array_keys($translations[$id]));
     }
 
     return $entities;
diff --git a/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.info.yml b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.info.yml
new file mode 100644
index 0000000000..f6c5769eb6
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.info.yml
@@ -0,0 +1,8 @@
+name: 'Entity Subclass Test'
+type: module
+description: 'Support module for testing entity type subclassing.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - entity_test
diff --git a/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module
new file mode 100644
index 0000000000..fdb86f809d
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_subclass/entity_test_subclass.module
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @file
+ * Support module for testing entity type subclassing.
+ */
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function entity_test_subclass_entity_type_alter(array &$entity_types) {
+  /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */
+  $entity_types['entity_test']->setStorageClass('\Drupal\entity_test_subclass\EntityTestSubclassStorage');
+}
diff --git a/core/modules/system/tests/modules/entity_test_subclass/src/Entity/EntityTestSubclass.php b/core/modules/system/tests/modules/entity_test_subclass/src/Entity/EntityTestSubclass.php
new file mode 100644
index 0000000000..c469d280f0
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_subclass/src/Entity/EntityTestSubclass.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Drupal\entity_test_subclass\Entity;
+
+use Drupal\entity_test\Entity\EntityTest;
+
+/**
+ * Provides a subclass for the entity_test entity type.
+ */
+class EntityTestSubclass extends EntityTest {
+}
diff --git a/core/modules/system/tests/modules/entity_test_subclass/src/EntityTestSubclassStorage.php b/core/modules/system/tests/modules/entity_test_subclass/src/EntityTestSubclassStorage.php
new file mode 100644
index 0000000000..25dac61093
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_subclass/src/EntityTestSubclassStorage.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\entity_test_subclass;
+
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+use Drupal\entity_test_subclass\Entity\EntityTestSubclass;
+
+/**
+ * Class EntityTestSubclassStorage.
+ */
+class EntityTestSubclassStorage extends SqlContentEntityStorage {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function bundleClasses() {
+    return [
+      'subclass' => EntityTestSubclass::class,
+    ];
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php
new file mode 100644
index 0000000000..aafbdb20d0
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntitySubclassTest.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Entity;
+
+use Drupal\entity_test_subclass\Entity\EntityTestSubclass;
+
+/**
+ * Tests adding a custom bundle field.
+ *
+ * @group Entity
+ */
+class EntitySubclassTest extends EntityKernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['entity_test_subclass'];
+
+  /**
+   * The entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $storage;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->storage = $this->entityManager->getStorage('entity_test');
+  }
+
+  /**
+   * Tests making use of a custom bundle field.
+   */
+  public function testEntitySubclass() {
+    entity_test_create_bundle('subclass');
+
+    // Verify statically created subclassed entity returns correct class.
+    $entity = EntityTestSubclass::create();
+    $this->assertTrue($entity instanceof EntityTestSubclass);
+
+    // Verify statically created subclassed entity returns correct bundle.
+    $entity = EntityTestSubclass::create(['type' => 'custom']);
+    $this->assertTrue($entity instanceof EntityTestSubclass);
+    $this->assertEquals('subclass', $entity->bundle());
+
+    // Verify that the entity storage creates the entity using the proper class.
+    $entity = $this->storage->create(['type' => 'subclass']);
+    $this->assertTrue($entity instanceof EntityTestSubclass);
+
+    // Verify that loading an entity returns the proper class.
+    $entity->save();
+    $id = $entity->id();
+    $this->storage->resetCache();
+    $entity = $this->storage->load($id);
+    $this->assertTrue($entity instanceof EntityTestSubclass);
+  }
+
+}
-- 
2.14.1

