Commit fc76f6e8 authored by pfrenssen's avatar pfrenssen Committed by larowlan
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
......@@ -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;
}
......
<?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;
}
......@@ -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}
*/
......
......@@ -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,34 +119,98 @@ 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])) {
throw new EntityStorageException('Missing bundle for entity type ' . $this->entityTypeId);
}
$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;
}
// Normalize the bundle value. This is an optimized version of
// \Drupal\Core\Field\FieldInputValueNormalizerTrait::normalizeValue()
// because we just need the scalar value.
$bundle_value = $values[$this->bundleKey];
if (!is_array($bundle_value)) {
// The bundle value is a scalar, use it as-is.
$bundle = $bundle_value;
}
elseif (is_numeric(array_keys($bundle_value)[0])) {
// The bundle value is a field item list array, keyed by delta.
$bundle = reset($bundle_value[0]);
/**
* {@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;
}
}
else {
// The bundle value is a field item array, keyed by the field's main
// property name.
$bundle = reset($bundle_value);
}
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()
// because we just need the scalar value.
$bundle_value = $values[$this->bundleKey];
if (!is_array($bundle_value)) {
// The bundle value is a scalar, use it as-is.
$bundle = $bundle_value;
}
elseif (is_numeric(array_keys($bundle_value)[0])) {
// The bundle value is a field item list array, keyed by delta.
$bundle = reset($bundle_value[0]);
}
else {
// The bundle value is a field item array, keyed by the field's main
// property name.
$bundle = reset($bundle_value);
}
return $bundle;
}
/**
* {@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;
}
$entity = new $this->entityClass([], $this->entityTypeId, $bundle);
$this->initFieldValues($entity, $values);
return $entity;
return $entity_class;
}
/**
......
......@@ -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;
$entity_class::postLoad($this, $entities);
$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,27 +482,24 @@ 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) {
$this->invokeHook('predelete', $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));
// 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, $keyed_entities);
foreach ($keyed_entities as $entity) {
$this->invokeHook('delete', $entity);
// Allow code to run after deleting.
$entity_class::postDelete($this, $items);
foreach ($items as $entity) {
$this->invokeHook('delete', $entity);
}
}
}
......@@ -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;
}
}
......@@ -230,4 +230,16 @@ public function getEntityTypeId();
*/
public function getEntityType();
/**
* Retrieves the class name used to create the entity.
*
* @param string|null $bundle
* (optional) A specific entity type bundle identifier. Can be omitted in
* the case of entity types without bundles, like User.
*
* @return string
* The entity class name.
*/
public function getEntityClass(?string $bundle = NULL): string;
}
......@@ -2,6 +2,7 @@
namespace Drupal\Core\Entity;
use Drupal\Core\Entity\Exception\AmbiguousBundleClassException;
use Drupal\Core\Entity\Exception\AmbiguousEntityClassException;
use Drupal\Core\Entity\Exception\NoCorrespondingEntityClassException;
use Drupal\Core\StringTranslation\StringTranslationTrait;
......@@ -80,7 +81,8 @@ public function getEntityTypeFromClass($class_name) {
$same_class = 0;
$entity_type_id = NULL;
foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
$definitions = $this->entityTypeManager->getDefinitions();
foreach ($definitions as $entity_type) {
if ($entity_type->getOriginalClass() == $class_name || $entity_type->getClass() == $class_name) {
$entity_type_id = $entity_type->id();
if ($same_class++) {
......@@ -89,6 +91,20 @@ public function getEntityTypeFromClass($class_name) {
}
}
// If no match was found check if it is a bundle class. This needs to be in
// a separate loop to avoid false positives, since an entity class can
// subclass another entity class.
if (!$entity_type_id) {
foreach ($definitions as $entity_type) {
if (is_subclass_of($class_name, $entity_type->getOriginalClass()) || is_subclass_of($class_name, $entity_type->getClass())) {
$entity_type_id = $entity_type->id();
if ($same_class++) {
throw new AmbiguousBundleClassException($class_name);
}
}
}
}
// Return the matching entity type ID if there is one.
if ($entity_type_id) {
$this->classNameEntityTypeMap[$class_name] = $entity_type_id;
......
......@@ -34,6 +34,8 @@ public function getEntityTypeLabels($group = FALSE);
*
* @throws \Drupal\Core\Entity\Exception\AmbiguousEntityClassException
* Thrown when multiple subclasses correspond to the called class.
* @throws \Drupal\Core\Entity\Exception\AmbiguousBundleClassException
* Thrown when multiple subclasses correspond to the called bundle class.
* @throws \Drupal\Core\Entity\Exception\NoCorrespondingEntityClassException
* Thrown when no entity class corresponds to the called class.
*
......
<?php
namespace Drupal\Core\Entity\Exception;
/**
* Exception thrown if a bundle class is defined for multiple bundles.
*
* @see \Drupal\Core\Entity\ContentEntityStorageBase::getBundleFromClass()
* @see \Drupal\Core\Entity\EntityTypeRepository::getEntityTypeFromClass()
*/
class AmbiguousBundleClassException extends AmbiguousEntityClassException {
/**
* Constructs an AmbiguousBundleClassException.
*
* @param string $class
* The bundle class which is defined for multiple bundles.
*/
public function __construct(string $class) {
$message = sprintf('Multiple bundles are using the bundle class %s.', $class);
parent::__construct($message);
}
}
<?php
namespace Drupal\Core\Entity\Exception;
/**
* Exception thrown if a bundle class does not extend the main entity class.
*
* @see \Drupal\Core\Entity\ContentEntityStorageBase::getEntityClass()
*/
class BundleClassInheritanceException extends \Exception {
/**
* Constructs a BundleClassInheritanceException.
*
* @param string $bundle_class
* The bundle class which should extend the entity class.
* @param string $entity_class
* The entity class which should be extended.
*/
public function __construct(string $bundle_class, string $entity_class) {
$message = sprintf('Bundle class %s does not extend entity class %s.', $bundle_class, $entity_class);
parent::__construct($message);
}
}
......@@ -93,7 +93,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
......
......@@ -503,9 +503,10 @@ protected function mapFromStorageRecords(array $records, $load_from_revision = F
$entities = [];
foreach ($values as $id => $entity_values) {
$bundle = $this->bundleKey ? $entity_values[$this->bundleKey][LanguageInterface::LANGCODE_DEFAULT] : FALSE;
$bundle = $this->bundleKey ? $entity_values[$this->bundleKey][LanguageInterface::LANGCODE_DEFAULT] : NULL;
// 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;
......
......@@ -848,6 +848,11 @@ function hook_entity_view_mode_info_alter(&$view_modes) {
* the entity type and the bundle, the one for the bundle is used.
* - translatable: (optional) A boolean value specifying whether this bundle