Commit fc76f6e8 authored by Pieter Frenssen's avatar Pieter Frenssen Committed by Lee Rowlands
Browse files

Issue #2570593 by dww, pfrenssen, markhalliwell, moshe weitzman, JeroenT, Chi,...

Issue #2570593 by dww, pfrenssen, markhalliwell, moshe weitzman, JeroenT, Chi, eiriksm, d70rr3s, deviantintegral, bradjones1, larowlan, dpi, sokru, claudiu.cristea, Berdir, jonathanshaw, catch, Kingdutch, alexpott, TravisCarden, johnwebdev, AaronMcHale, sime, e0ipso: Allow entities to be subclassed using "bundle classes"
parent 384d615a
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -214,7 +214,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;
  }
+25 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Entity;

/**
 * A storage that supports entities with bundle specific classes.
 */
interface BundleEntityStorageInterface {

  /**
   * 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.
   *
   * @throws \Drupal\Core\Entity\Exception\AmbiguousBundleClassException
   *   Thrown when multiple bundles are using the provided class.
   */
  public function getBundleFromClass(string $class_name): ?string;

}
+17 −0
Original line number Diff line number Diff line
@@ -1126,6 +1126,23 @@ public function __unset($name) {
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function create(array $values = []) {
    $entity_type_repository = \Drupal::service('entity_type.repository');
    $entity_type_manager = \Drupal::entityTypeManager();
    $class_name = static::class;
    $storage = $entity_type_manager->getStorage($entity_type_repository->getEntityTypeFromClass($class_name));

    // Always explicitly specify the bundle if the entity has a bundle class.
    if ($storage instanceof BundleEntityStorageInterface && ($bundle = $storage->getBundleFromClass($class_name))) {
      $values[$storage->getEntityType()->getKey('bundle')] = $bundle;
    }

    return $storage->create($values);
  }

  /**
   * {@inheritdoc}
   */
+118 −25
Original line number Diff line number Diff line
@@ -5,6 +5,8 @@
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Cache\MemoryCache\MemoryCacheInterface;
use Drupal\Core\Entity\Exception\AmbiguousBundleClassException;
use Drupal\Core\Entity\Exception\BundleClassInheritanceException;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Language\LanguageInterface;
@@ -14,7 +16,7 @@
/**
 * Base class for content entity storage handlers.
 */
abstract class ContentEntityStorageBase extends EntityStorageBase implements ContentEntityStorageInterface, DynamicallyFieldableEntityStorageInterface {
abstract class ContentEntityStorageBase extends EntityStorageBase implements ContentEntityStorageInterface, DynamicallyFieldableEntityStorageInterface, BundleEntityStorageInterface {

  /**
   * The entity bundle key.
@@ -73,6 +75,33 @@ public function __construct(EntityTypeInterface $entity_type, EntityFieldManager
    $this->entityTypeBundleInfo = $entity_type_bundle_info;
  }

  /**
   * {@inheritdoc}
   */
  public function create(array $values = []) {
    $bundle = $this->getBundleFromValues($values);
    $entity_class = $this->getEntityClass($bundle);
    // @todo Decide what to do if preCreate() tries to change the bundle.
    // @see https://www.drupal.org/project/drupal/issues/3230792
    $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}
   */
@@ -90,12 +119,55 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
   * {@inheritdoc}
   */
  protected function doCreate(array $values) {
    // We have to determine the bundle first.
    $bundle = FALSE;
    if ($this->bundleKey) {
      if (!isset($values[$this->bundleKey])) {
    $bundle = $this->getBundleFromValues($values);
    if ($this->bundleKey && !$bundle) {
      throw new EntityStorageException('Missing bundle for entity type ' . $this->entityTypeId);
    }
    $entity_class = $this->getEntityClass($bundle);
    $entity = new $entity_class([], $this->entityTypeId, $bundle);
    $this->initFieldValues($entity, $values);
    return $entity;
  }

  /**
   * {@inheritdoc}
   */
  public function getBundleFromClass(string $class_name): ?string {
    $bundle_for_class = NULL;

    foreach ($this->entityTypeBundleInfo->getBundleInfo($this->entityTypeId) as $bundle => $bundle_info) {
      if (!empty($bundle_info['class']) && $bundle_info['class'] === $class_name) {
        if ($bundle_for_class) {
          throw new AmbiguousBundleClassException($class_name);
        }
        else {
          $bundle_for_class = $bundle;
        }
      }
    }

    return $bundle_for_class;
  }

  /**
   * Retrieves the bundle from an array of values.
   *
   * @param array $values
   *   An array of values to set, keyed by field name.
   *
   * @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.
   */
  protected function getBundleFromValues(array $values): ?string {
    $bundle = NULL;

    // Make sure we have a reasonable bundle key. If not, bail early.
    if (!$this->bundleKey || !isset($values[$this->bundleKey])) {
      return NULL;
    }

    // Normalize the bundle value. This is an optimized version of
    // \Drupal\Core\Field\FieldInputValueNormalizerTrait::normalizeValue()
@@ -114,10 +186,31 @@ protected function doCreate(array $values) {
      // property name.
      $bundle = reset($bundle_value);
    }
    return $bundle;
  }
    $entity = new $this->entityClass([], $this->entityTypeId, $bundle);
    $this->initFieldValues($entity, $values);
    return $entity;

  /**
   * {@inheritdoc}
   */
  public function getEntityClass(?string $bundle = NULL): string {
    $entity_class = parent::getEntityClass();

    // If no bundle is set, use the entity type ID as the bundle ID.
    $bundle = $bundle ?? $this->getEntityTypeId();

    // Return the bundle class if it has been defined for this bundle.
    $bundle_info = $this->entityTypeBundleInfo->getBundleInfo($this->entityTypeId);
    $bundle_class = $bundle_info[$bundle]['class'] ?? NULL;

    // Bundle classes should extend the main entity class.
    if ($bundle_class) {
      if (!is_subclass_of($bundle_class, $entity_class)) {
        throw new BundleClassInheritanceException($bundle_class, $entity_class);
      }
      return $bundle_class;
    }

    return $entity_class;
  }

  /**
+116 −26
Original line number Diff line number Diff line
@@ -60,11 +60,20 @@ abstract class EntityStorageBase extends EntityHandlerBase implements EntityStor
  protected $uuidService;

  /**
   * Name of the entity class.
   * Name of the entity class (if set directly via deprecated means).
   *
   * @var string
   * This is a private property since it's only here to support backwards
   * compatibility for deprecated code paths in contrib and custom code.
   * Normally, the entity class is defined via an annotation when defining an
   * entity type, via hook_entity_bundle_info() or via
   * hook_entity_bundle_info_alter().
   *
   * @todo Remove this in Drupal 10.
   * @see https://www.drupal.org/project/drupal/issues/3244802
   *
   * @var string|null
   */
  protected $entityClass;
  private $baseEntityClass;

  /**
   * The memory cache.
@@ -94,11 +103,53 @@ public function __construct(EntityTypeInterface $entity_type, MemoryCacheInterfa
    $this->idKey = $this->entityType->getKey('id');
    $this->uuidKey = $this->entityType->getKey('uuid');
    $this->langcodeKey = $this->entityType->getKey('langcode');
    $this->entityClass = $this->entityType->getClass();
    $this->memoryCache = $memory_cache;
    $this->memoryCacheTag = 'entity.memory_cache:' . $this->entityTypeId;
  }

  /**
   * {@inheritdoc}
   */
  public function getEntityClass(?string $bundle = NULL): string {
    // @todo Simplify this in Drupal 10 to return $this->entityType->getClass().
    // @see https://www.drupal.org/project/drupal/issues/3244802
    return $this->baseEntityClass ?? $this->entityType->getClass();
  }

  /**
   * Warns subclasses not to directly access the deprecated entityClass property.
   *
   * @param string $name
   *   The name of the property to get.
   *
   * @todo Remove this in Drupal 10.
   * @see https://www.drupal.org/project/drupal/issues/3244802
   */
  public function __get($name) {
    if ($name === 'entityClass') {
      @trigger_error('Accessing the entityClass property directly is deprecated in drupal:9.3.0. Use ::getEntityClass() instead. See https://www.drupal.org/node/3191609', E_USER_DEPRECATED);
      return $this->getEntityClass();
    }
  }

  /**
   * Warns subclasses not to directly set the deprecated entityClass property.
   *
   * @param string $name
   *   The name of the property to set.
   * @param mixed $value
   *   The value to use.
   *
   * @todo Remove this in Drupal 10.
   * @see https://www.drupal.org/project/drupal/issues/3244802
   */
  public function __set(string $name, $value): void {
    if ($name === 'entityClass') {
      @trigger_error('Setting the entityClass property directly is deprecated in drupal:9.3.0 and has no effect in drupal:10.0.0. See https://www.drupal.org/node/3191609', E_USER_DEPRECATED);
      $this->baseEntityClass = $value;
    }
  }

  /**
   * {@inheritdoc}
   */
@@ -205,7 +256,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.
@@ -234,7 +285,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);
  }

  /**
@@ -349,12 +401,33 @@ protected function preLoad(array &$ids = NULL) {
  /**
   * Attaches data to entities upon loading.
   *
   * If there are multiple bundle classes involved, each one gets a sub array
   * with only the entities of the same bundle. If there's only a single bundle,
   * the entity's postLoad() method will get a copy of the original $entities
   * array.
   *
   * @param array $entities
   *   Associative array of query results, keyed on the entity ID.
   */
  protected function postLoad(array &$entities) {
    $entity_class = $this->entityClass;
    $entities_by_class = $this->getEntitiesByClass($entities);

    // Invoke entity class specific postLoad() methods. If there's only a single
    // class involved, we want to pass in the original $entities array. For
    // example, to provide backwards compatibility with the legacy behavior of
    // the deprecated user_roles() method, \Drupal\user\Entity\Role::postLoad()
    // sorts the array to enforce role weights. We have to let it manipulate the
    // final array, not a subarray. However if there are multiple bundle classes
    // involved, we only want to pass each one the entities that match.
    if (count($entities_by_class) === 1) {
      $entity_class = array_key_first($entities_by_class);
      $entity_class::postLoad($this, $entities);
    }
    else {
      foreach ($entities_by_class as $entity_class => &$items) {
        $entity_class::postLoad($this, $items);
      }
    }
    // Call hook_entity_load().
    foreach ($this->moduleHandler()->getImplementations('entity_load') as $module) {
      $function = $module . '_entity_load';
@@ -379,7 +452,9 @@ 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();
      /** @var \Drupal\Core\Entity\EntityInterface $entity */
      $entity = new $entity_class($record, $this->entityTypeId);
      $entities[$entity->id()] = $entity;
    }
    return $entities;
@@ -394,6 +469,7 @@ protected function mapFromStorageRecords(array $records) {
   *   The entity being saved.
   *
   * @return bool
   *   TRUE if this entity exists in storage, FALSE otherwise.
   */
  abstract protected function has($id, EntityInterface $entity);

@@ -406,29 +482,26 @@ public function delete(array $entities) {
      return;
    }

    // Ensure that the entities are keyed by ID.
    $keyed_entities = [];
    foreach ($entities as $entity) {
      $keyed_entities[$entity->id()] = $entity;
    }
    $entities_by_class = $this->getEntitiesByClass($entities);

    // Allow code to run before deleting.
    $entity_class = $this->entityClass;
    $entity_class::preDelete($this, $keyed_entities);
    foreach ($keyed_entities as $entity) {
    foreach ($entities_by_class as $entity_class => &$items) {
      $entity_class::preDelete($this, $items);
      foreach ($items as $entity) {
        $this->invokeHook('predelete', $entity);
      }

      // Perform the delete and reset the static cache for the deleted entities.
    $this->doDelete($keyed_entities);
    $this->resetCache(array_keys($keyed_entities));
      $this->doDelete($items);
      $this->resetCache(array_keys($items));

      // Allow code to run after deleting.
    $entity_class::postDelete($this, $keyed_entities);
    foreach ($keyed_entities as $entity) {
      $entity_class::postDelete($this, $items);
      foreach ($items as $entity) {
        $this->invokeHook('delete', $entity);
      }
    }
  }

  /**
   * Performs storage-specific entity deletion.
@@ -605,4 +678,21 @@ public function getAggregateQuery($conjunction = 'AND') {
   */
  abstract protected function getQueryServiceName();

  /**
   * Indexes the given array of entities by their class name and ID.
   *
   * @param \Drupal\Core\Entity\EntityInterface[] $entities
   *   The array of entities to index.
   *
   * @return \Drupal\Core\Entity\EntityInterface[][]
   *   An array of the passed-in entities, indexed by their class name and ID.
   */
  protected function getEntitiesByClass(array $entities): array {
    $entity_classes = [];
    foreach ($entities as $entity) {
      $entity_classes[get_class($entity)][$entity->id()] = $entity;
    }
    return $entity_classes;
  }

}
Loading