diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index a004ebad81..d4271998b3 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php @@ -21,6 +21,13 @@ */ protected $bundleKey = FALSE; + /** + * Static cache of entity revisions, keyed by entity revision ID. + * + * @var array + */ + protected $entityRevisions = []; + /** * The entity manager. * @@ -35,6 +42,19 @@ */ protected $cacheBackend; + /** + * Whether the static revision cache should be ignored when loading an entity. + * + * This property will be set when loading the unchanged entity or entity + * revision in order for ::load() and ::loadRevision() to load the entity + * without using the static entity cache or static entity revision cache. + * @see ::loadUnchanged() + * @see ::loadRevisionUnchanged() + * + * @var bool + */ + protected $ignoreStaticRevisionCache = FALSE; + /** * Constructs a ContentEntityStorageBase object. * @@ -446,6 +466,41 @@ public function purgeFieldData(FieldDefinitionInterface $field_definition, $batc */ public function finalizePurge(FieldStorageDefinitionInterface $storage_definition) {} + /** + * {@inheritdoc} + */ + public function load($id) { + $entities = $this->getFromStaticCache([$id]); + $entity = isset($entities[$id]) ? $entities[$id] : NULL; + + // If the entity is not present in the static entity cache then we check if + // the default revision for the given entity ID is present in the static + // entity revision cache. + if (!$entity && !$this->ignoreStaticRevisionCache && $this->entityType->isStaticallyCacheable() && $this->entityType->isRevisionable()) { + $default_revision_id = $this->getDefaultRevisionId($id, 'entity_load'); + if ($default_revision_id) { + $revisions = $this->getFromStaticCache([$default_revision_id], TRUE); + $entity = isset($revisions[$default_revision_id]) ? $revisions[$default_revision_id] : NULL; + // If we've loaded the entity from the static entity revision cache then + // we put the entity object in the static entity cache as well. + if ($entity) { + $this->setStaticCache([$id => $entity]); + } + } + } + // If we couldn't load the entity from the static caches then fallback to + // the parent to retrieve it from the storage. + if (!$entity) { + $entity = parent::load($id); + if ($entity) { + // Put the entity into the static entity revision cache. + $this->setStaticCache([$entity->getRevisionId() => $entity], TRUE); + } + } + + return $entity; + } + /** * {@inheritdoc} */ @@ -459,26 +514,63 @@ public function loadRevision($revision_id) { * {@inheritdoc} */ public function loadMultipleRevisions(array $revision_ids) { - $revisions = $this->doLoadMultipleRevisionsFieldItems($revision_ids); + $revisions = []; + // Create a new variable which is either a prepared version of the + // $revision_ids array for later comparison with the entity cache, or FALSE + // if no $revision_ids were passed. The $revision_ids array is reduced as + // items are loaded from cache, and we need to know if it's empty for this + // reason to avoid querying the database when all requested entities are + // loaded from cache. + $passed_revision_ids = !empty($revision_ids) ? array_flip($revision_ids) : FALSE; + // Try to load entities from the static cache, if the entity type supports + // static caching. + if (!$this->ignoreStaticRevisionCache && $this->entityType->isStaticallyCacheable() && $revision_ids) { + $revisions += $this->getFromStaticCache($revision_ids, TRUE); + // If any entities were loaded, remove them from the IDs still to load. + $revision_ids = array_keys(array_diff_key($passed_revision_ids, $revisions)); + } + + // Load any remaining entities from the database. This is the case if there + // are any revision IDs left to load. + $queried_revisions = $this->doLoadMultipleRevisionsFieldItems($revision_ids); + // Add entities to the static cache. + if (!$this->ignoreStaticRevisionCache && $this->entityType->isStaticallyCacheable() && !empty($queried_revisions)) { + $this->setStaticCache($queried_revisions, TRUE); + } + + // Pass all entities loaded from the database through $this->postLoad(), + // which attaches fields (if supported by the entity type) and calls the + // entity type specific load callback, for example hook_node_load(). // The hooks are executed with an array of entities keyed by the entity ID. // As we could load multiple revisions for the same entity ID at once we // have to build groups of entities where the same entity ID is present only // once. $entity_groups = []; $entity_group_mapping = []; - foreach ($revisions as $revision) { + foreach ($queried_revisions as $revision) { $entity_id = $revision->id(); $entity_group_key = isset($entity_group_mapping[$entity_id]) ? $entity_group_mapping[$entity_id] + 1 : 0; $entity_group_mapping[$entity_id] = $entity_group_key; $entity_groups[$entity_group_key][$entity_id] = $revision; } - // Invoke the entity hooks for each group. foreach ($entity_groups as $entities) { - $this->invokeStorageLoadHook($entities); $this->postLoad($entities); } + $revisions += $queried_revisions; + + // Ensure that the returned array is ordered the same as the original + // $revision_ids array if this was passed in and remove any invalid revision + // IDs. + if ($passed_revision_ids) { + // Remove any invalid revision IDs from the array. + $passed_revision_ids = array_intersect_key($passed_revision_ids, $revisions); + foreach ($revisions as $revision_id => $revision) { + $passed_revision_ids[$revision_id] = $revision; + } + $revisions = $passed_revision_ids; + } return $revisions; } @@ -608,8 +700,67 @@ protected function doPreSave(EntityInterface $entity) { protected function doPostSave(EntityInterface $entity, $update) { /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ - if ($update && $this->entityType->isTranslatable()) { - $this->invokeTranslationHooks($entity); + if ($update) { + if ($this->entityType->isTranslatable()) { + $this->invokeTranslationHooks($entity); + } + if ($this->entityType->isRevisionable()) { + $revision_ids = [$entity->getLoadedRevisionId()]; + // If the entity has been saved into a new revision, then we only have + // to remove the original one from the static cache only. Otherwise we + // have to delete the persistent cache as well. + $this->resetStaticRevisionCache($revision_ids); + if ($this->entityType->isPersistentlyCacheable()) { + if (!$entity->isNewRevision()) { + $this->resetPersistentRevisionCache($revision_ids); + } + // If a non-revisionable field is changed, we have to delete the + // revision caches for that entity. + // Non-revisionable fields are allowed to change only in the default + // revision. + if ($entity->isDefaultRevision()) { + $changed_non_rev_field = FALSE; + /** @var \Drupal\Core\Entity\ContentEntityInterface $original */ + $original = $entity->original; + // Exclude the revision ID field explicitly as it is not flagged as + // revisionable. + $field_definitions = array_diff_key($entity->getFieldDefinitions(), [$this->entityType->getKey('revision') => TRUE]); + foreach ($field_definitions as $name => $field_definition) { + if (!$field_definition->getFieldStorageDefinition()->isRevisionable()) { + if ($field_definition->isTranslatable()) { + $current_langcode = $entity->language()->getId(); + if ($original->hasTranslation($current_langcode)) { + $original_translation = $original->getTranslation($current_langcode); + if (!$original_translation->get($name)->equals($entity->get($name))) { + $changed_non_rev_field = TRUE; + break; + } + } + foreach (array_diff(array_keys($entity->getTranslationLanguages()), [$current_langcode]) as $langcode) { + if ($original->hasTranslation($langcode)) { + $original_translation = $original->getTranslation($langcode); + $translation = $entity->getTranslation($langcode); + if (!$original_translation->get($name)->equals($translation->get($name))) { + $changed_non_rev_field = TRUE; + break 2; + } + } + } + } + else { + if (!$original->get($name)->equals($entity->get($name))) { + $changed_non_rev_field = TRUE; + break; + } + } + } + } + if ($changed_non_rev_field) { + $this->resetPersistentRevisionCache([$entity->id()], FALSE); + } + } + } + } } parent::doPostSave($entity, $update); @@ -630,6 +781,8 @@ protected function doDelete($entities) { $this->invokeFieldMethod('delete', $entity); } $this->doDeleteFieldItems($entities); + // Reset the persistent revision cache. + $this->resetPersistentRevisionCache(array_keys($entities), FALSE); } /** @@ -650,9 +803,13 @@ public function deleteRevision($revision_id) { if ($revision->isDefaultRevision()) { throw new EntityStorageException('Default revision can not be deleted'); } + // Perform the delete and reset the cache for the deleted revision. $this->invokeFieldMethod('deleteRevision', $revision); $this->doDeleteRevisionFieldItems($revision); $this->invokeHook('revision_delete', $revision); + $revision_ids = [$revision_id]; + $this->resetStaticRevisionCache($revision_ids); + $this->resetPersistentRevisionCache($revision_ids); } } @@ -897,19 +1054,33 @@ protected function cleanIds(array $ids, $entity_key = 'id') { * @param array|null &$ids * If not empty, return entities that match these IDs. IDs that were found * will be removed from the list. + * @param bool $revision + * (optional) Defines whether the $ids property contains IDs or revision IDs + * and respectively whether the entities should be loaded from the entity or + * from the entity revision cache. TRUE to retrieve the entities from the + * entity revision cache by revision IDs, FALSE to use the entity cache. + * Defaults to FALSE. * * @return \Drupal\Core\Entity\ContentEntityInterface[] * Array of entities from the persistent cache. */ - protected function getFromPersistentCache(array &$ids = NULL) { + protected function getFromPersistentCache(array &$ids = NULL, $revision = FALSE) { if (!$this->entityType->isPersistentlyCacheable() || empty($ids)) { return []; } + /** @var \Drupal\Core\Entity\ContentEntityInterface[] $entities */ $entities = []; // Build the list of cache entries to retrieve. $cid_map = []; - foreach ($ids as $id) { - $cid_map[$id] = $this->buildCacheId($id); + if ($revision) { + foreach ($ids as $id) { + $cid_map[$id] = $this->buildRevisionCacheId($id); + } + } + else { + foreach ($ids as $id) { + $cid_map[$id] = $this->buildCacheId($id); + } } $cids = array_values($cid_map); if ($cache = $this->cacheBackend->getMultiple($cids)) { @@ -922,6 +1093,19 @@ protected function getFromPersistentCache(array &$ids = NULL) { } } } + // We might have saved already a new entity revision as the default one so + // on fetching from the persistent cache we have to update the default + // revision status of the entity revisions. + if ($revision && $entities) { + $rev_ids_mapping = []; + foreach ($entities as $entity) { + $rev_ids_mapping[] = $entity->id(); + } + $rev_ids_mapping = $this->getDefaultRevisionIds($rev_ids_mapping); + foreach ($entities as $rev_id => $entity) { + $entity->isDefaultRevision(isset($rev_ids_mapping[$rev_id])); + } + } return $entities; } @@ -940,8 +1124,22 @@ protected function setPersistentCache($entities) { $this->entityTypeId . '_values', 'entity_field_info', ]; - foreach ($entities as $id => $entity) { - $this->cacheBackend->set($this->buildCacheId($id), $entity, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + foreach ($entities as $entity) { + // Persist only the default revision both in the entity and entity + // revision persistent cache. + if ($this->entityType->isRevisionable()) { + if ($entity->isDefaultRevision()) { + $this->cacheBackend->set($this->buildCacheId($entity->id()), $entity, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + + $revision_cache_tags = array_merge($cache_tags, [ + $this->entityTypeId . ':' . $entity->id() . ':revision_values', + ]); + $this->cacheBackend->set($this->buildRevisionCacheId($entity->getRevisionId()), $entity, CacheBackendInterface::CACHE_PERMANENT, $revision_cache_tags); + } + } + else { + $this->cacheBackend->set($this->buildCacheId($entity->id()), $entity, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + } } } @@ -951,6 +1149,9 @@ protected function setPersistentCache($entities) { public function loadUnchanged($id) { $ids = [$id]; + // Reset the static entity revision cache. + $revision_ids = $this->getLoadedRevisionIDsOfStaticallyCachedEntities($ids); + $this->resetStaticRevisionCache($revision_ids); // The cache invalidation in the parent has the side effect that loading the // same entity again during the save process (for example in // hook_entity_presave()) will load the unchanged entity. Simulate this @@ -966,7 +1167,10 @@ public function loadUnchanged($id) { $entities = $this->getFromPersistentCache($ids); if (!$entities) { + // We should prevent loading the entity from the static revision cache. + $this->ignoreStaticRevisionCache = TRUE; $entities[$id] = $this->load($id); + $this->ignoreStaticRevisionCache = FALSE; } else { // As the entities are put into the persistent cache before the post load @@ -991,22 +1195,177 @@ public function loadUnchanged($id) { public function resetCache(array $ids = NULL) { if ($ids) { $cids = []; + $revision_ids = $this->getLoadedRevisionIDsOfStaticallyCachedEntities($ids); foreach ($ids as $id) { unset($this->entities[$id]); $cids[] = $this->buildCacheId($id); } + // There is no need to reset the persistent entity revision cache. This + // normally is done only in the following cases: + // - an entity is deleted. + // - an entity revision is deleted. + // - a default entity revision with changes in non-revisionable fields is + // saved. + // - a non-default entity revision is saved in the same revision. + $this->resetStaticRevisionCache($revision_ids); if ($this->entityType->isPersistentlyCacheable()) { $this->cacheBackend->deleteMultiple($cids); } } else { $this->entities = []; + $this->entityRevisions = []; if ($this->entityType->isPersistentlyCacheable()) { Cache::invalidateTags([$this->entityTypeId . '_values']); } } } + /** + * Returns the revision IDs of statically cached entities. + * + * @param $ids + * (optional) If specified, the revision IDs will be retrieved of the + * statically cached entities with the given IDs only, otherwise the + * revision IDs of all statically cached entities will be retrieved. + * + * @return array + * The revision IDs. + */ + protected function getLoadedRevisionIDsOfStaticallyCachedEntities(array $ids = NULL) { + $revision_ids = []; + if (!$this->entityType->isRevisionable()) { + return $revision_ids; + } + $entities = isset($ids) ? array_intersect_key($this->entities, array_flip($ids)) : $this->entities; + /** @var \Drupal\Core\Entity\ContentEntityInterface[] $entities */ + foreach ($entities as $entity) { + $revision_ids[] = $entity->getLoadedRevisionId(); + } + return $revision_ids; + } + + /** + * Resets the static entity revision cache. + * + * @param $revision_ids + * The revision IDs to reset the static revision cache for. + */ + protected function resetStaticRevisionCache(array $revision_ids) { + if ($this->entityType->isStaticallyCacheable()) { + foreach ($revision_ids as $revision_id) { + unset($this->entityRevisions[$revision_id]); + } + } + } + + /** + * Resets the persistent entity revision cache. + * + * @param $ids + * The IDs to reset the persistent revision cache for. + * @param bool $revision + * (optional) Defines whether the $ids property contains IDs or revision + * IDs. TRUE if it contains revision IDs, FALSE if it contains IDs. Defaults + * to TRUE. + */ + protected function resetPersistentRevisionCache(array $ids, $revision = TRUE) { + if ($this->entityType->isPersistentlyCacheable()) { + if ($revision) { + $revision_ids = array_filter($ids); + if ($revision_ids) { + $cids = []; + foreach ($revision_ids as $revision_id) { + $cids[] = $this->buildRevisionCacheId($revision_id); + } + $this->cacheBackend->deleteMultiple($cids); + } + } + else { + $tags = []; + foreach ($ids as $id) { + $tags[] = $this->entityTypeId . ':' . $id . ':revision_values'; + } + Cache::invalidateTags($tags); + } + } + } + + /** + * Gets entities from the static cache. + * + * @param array $ids + * If not empty, return entities that match these IDs. + * @param bool $revision + * (optional) Defines whether the $ids property contains IDs or revision IDs + * and respectively whether the entities should be loaded from the entity or + * from the entity revision cache. TRUE to retrieve the entities from the + * static entity revision cache by revision IDs, FALSE to use the static + * entity cache. Defaults to FALSE. + * + * @return \Drupal\Core\Entity\ContentEntityInterface[] + * Array of entities from the entity cache. + */ + protected function getFromStaticCache(array $ids, $revision = FALSE) { + $entities = []; + if ($revision) { + // Load any available entities from the internal entity revision cache. + if ($this->entityType->isStaticallyCacheable() && !empty($this->entityRevisions)) { + $entities += array_intersect_key($this->entityRevisions, array_flip($ids)); + } + } + else { + $entities = parent::getFromStaticCache($ids); + } + return $entities; + } + + /** + * Stores entities in the static entity cache. + * + * @param \Drupal\Core\Entity\ContentEntityInterface[] $entities + * Entities to store in the cache. + * @param bool $revision + * (optional) Defines whether the entities should be put into the static + * entity or entity revision cache. TRUE to put the entities into the static + * entity revision cache, FALSE to put them into the static entity cache. + * Defaults to FALSE. + */ + protected function setStaticCache(array $entities, $revision = FALSE) { + if ($revision) { + if ($this->entityType->isStaticallyCacheable()) { + foreach ($entities as $revision_id => $entity) { + $this->entityRevisions[$revision_id] = $entity; + } + } + } + else { + parent::setStaticCache($entities); + } + } + + /** + * {@inheritdoc} + */ + public function loadRevisionUnchanged($revision_id) { + // Load the revision by ignoring the static entity and entity revision + // cache. + $revision_ids = [$revision_id]; + $revisions = $this->getFromPersistentCache($revision_ids, TRUE); + if ($revisions) { + $revision = $revisions[$revision_id]; + $entities = [$revision->id() => $revision]; + $this->postLoad($entities); + } + else { + $this->ignoreStaticRevisionCache = TRUE; + $revision = $this->loadRevision($revision_id); + $this->ignoreStaticRevisionCache = FALSE; + } + + return $revision; + } + /** * Builds the cache ID for the passed in entity ID. * @@ -1020,4 +1379,63 @@ protected function buildCacheId($id) { return "values:{$this->entityTypeId}:$id"; } + /** + * Builds the cache ID for the passed in entity revision ID. + * + * @param int $revision_id + * Entity revision ID for which the cache ID should be built. + * + * @return string + * Cache ID that can be passed to the cache backend. + */ + protected function buildRevisionCacheId($revision_id) { + return "values:{$this->entityTypeId}:revision:$revision_id"; + } + + /** + * Fetches the current default revision id. + * + * @param int $id + * The entity id. + * @param string|null $tag + * (optional) The tag to add to the entity query. + * + * @return int|null + * The default revision id. + */ + protected function getDefaultRevisionId($id, $tag = NULL) { + $result = NULL; + if ($this->entityType->isRevisionable()) { + $query = $this->getQuery() + ->condition($this->entityType->getKey('id'), $id); + if ($tag) { + // We add a tag to the query, which could be checked for in the query + // hooks or in the query preparation phase in order to prevent endless + // loops if an entity load is occurring there. + $query->addTag($tag); + } + $result = $query->execute(); + } + return $result ? key($result) : NULL; + } + + /** + * Fetches the current default revision IDs for the given entity IDs. + * + * @param array $ids + * The entity IDs. + * + * @return array + * An array of entity IDs keyed by the corresponding default revision IDs. + */ + protected function getDefaultRevisionIds($ids) { + $result = []; + if ($this->entityType->isRevisionable()) { + $query = $this->getQuery() + ->condition($this->entityType->getKey('id'), $ids, 'IN'); + $result = $query->execute(); + } + return $result; + } + } diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php index 6aa18ff840..8baa89b55b 100644 --- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php @@ -12,6 +12,14 @@ */ class KeyValueContentEntityStorage extends KeyValueEntityStorage implements ContentEntityStorageInterface { + /** + * {@inheritdoc} + */ + public function loadRevisionUnchanged($revision_id) { + // @todo Complete the content entity storage implementation in + // https://www.drupal.org/node/2618436. + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php index d23808b1b6..ebafc871c9 100644 --- a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php @@ -44,6 +44,20 @@ public function loadRevision($revision_id); */ public function loadMultipleRevisions(array $revision_ids); + /** + * Loads an unchanged entity by revision id from the database. + * + * @param int $revision_id + * The revision ID of the entity to load. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The unchanged entity, or NULL if the entity cannot be loaded. + * + * @todo Remove this method once we have a reliable way to retrieve the + * unchanged entity from the entity object. + */ + public function loadRevisionUnchanged($revision_id); + /** * Deletes a specific entity revision. * diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 3fefd5e763..6aae38710e 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -556,12 +556,14 @@ protected function doLoadRevisionFieldItems($revision_id) { * {@inheritdoc} */ protected function doLoadMultipleRevisionsFieldItems($revision_ids) { - $revisions = []; - // Sanitize IDs. Before feeding ID array into buildQuery, check whether // it is empty as this would load all entity revisions. $revision_ids = $this->cleanIds($revision_ids, 'revision'); + // Attempt to load entities from the persistent cache. This will remove IDs + // that were loaded from $ids. + $revisions = $this->getFromPersistentCache($revision_ids, TRUE); + if (!empty($revision_ids)) { // Build and execute the query. $query_result = $this->buildQuery(NULL, $revision_ids)->execute(); @@ -569,7 +571,28 @@ protected function doLoadMultipleRevisionsFieldItems($revision_ids) { // Map the loaded records into entity objects and according fields. if ($records) { - $revisions = $this->mapFromStorageRecords($records, TRUE); + $revisions_from_storage = $this->mapFromStorageRecords($records, TRUE); + + // The hooks are executed with an array of entities keyed by the entity + // ID. As we could load multiple revisions for the same entity ID at + // once we have to build groups of entities where the same entity ID is + // present only once. + $entity_groups = []; + $entity_group_mapping = []; + foreach ($revisions_from_storage as $revision) { + $entity_id = $revision->id(); + $entity_group_key = isset($entity_group_mapping[$entity_id]) ? $entity_group_mapping[$entity_id] + 1 : 0; + $entity_group_mapping[$entity_id] = $entity_group_key; + $entity_groups[$entity_group_key][$entity_id] = $revision; + } + + // Invoke the entity hooks for each group. + foreach ($entity_groups as $entities) { + $this->invokeStorageLoadHook($entities); + } + $this->setPersistentCache($revisions_from_storage); + + $revisions += $revisions_from_storage; } } diff --git a/core/modules/aggregator/tests/src/Functional/Rest/ItemResourceTestBase.php b/core/modules/aggregator/tests/src/Functional/Rest/ItemResourceTestBase.php index c7163e098a..f68af40e52 100644 --- a/core/modules/aggregator/tests/src/Functional/Rest/ItemResourceTestBase.php +++ b/core/modules/aggregator/tests/src/Functional/Rest/ItemResourceTestBase.php @@ -81,15 +81,9 @@ protected function createEntity() { /** * {@inheritdoc} */ - protected function createAnotherEntity() { - $entity = $this->entity->createDuplicate(); - $entity->setLink('https://www.example.org/'); - $label_key = $entity->getEntityType()->getKey('label'); - if ($label_key) { - $entity->set($label_key, $entity->label() . '_dupe'); - } - $entity->save(); - return $entity; + protected function createAnotherEntity(array $values = []) { + $values['link'] = 'https://www.example.org/'; + return parent::createAnotherEntity($values); } /** diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php index abb92eb841..6637eff4dd 100644 --- a/core/modules/content_moderation/src/Entity/ContentModerationState.php +++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php @@ -198,7 +198,7 @@ public static function getCurrentUserId() { public function save() { $related_entity = \Drupal::entityTypeManager() ->getStorage($this->content_entity_type_id->value) - ->loadRevision($this->content_entity_revision_id->value); + ->loadRevisionUnchanged($this->content_entity_revision_id->value); if ($related_entity instanceof TranslatableInterface) { $related_entity = $related_entity->getTranslation($this->activeLangcode); } diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php index f42ec33558..a95b7e6c30 100644 --- a/core/modules/content_moderation/src/ModerationInformation.php +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -75,7 +75,7 @@ public function shouldModerateEntitiesOfBundle(EntityTypeInterface $entity_type, */ public function getLatestRevision($entity_type_id, $entity_id) { if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) { - return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id); + return $this->entityTypeManager->getStorage($entity_type_id)->loadRevisionUnchanged($latest_revision_id); } } @@ -170,7 +170,10 @@ public function isLiveRevision(ContentEntityInterface $entity) { */ public function isDefaultRevisionPublished(ContentEntityInterface $entity) { $workflow = $this->getWorkflowForEntity($entity); - $default_revision = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId())->load($entity->id()); + // We have to load the unchanged entity for the default revision to ensure + // that we are not retrieving a modified entity from the static entity + // cache. + $default_revision = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id()); // Ensure we are checking all translations of the default revision. if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) { diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php index 739c16b842..0e37ebc758 100644 --- a/core/modules/content_moderation/src/ModerationInformationInterface.php +++ b/core/modules/content_moderation/src/ModerationInformationInterface.php @@ -48,7 +48,7 @@ public function canModerateEntitiesOfEntityType(EntityTypeInterface $entity_type public function shouldModerateEntitiesOfBundle(EntityTypeInterface $entity_type, $bundle); /** - * Loads the latest revision of a specific entity. + * Loads the unmodified latest revision of a specific entity. * * @param string $entity_type_id * The entity type ID. diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php index c33ccacdee..e1e97b0ff9 100644 --- a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php +++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -103,7 +103,12 @@ protected function loadContentModerationStateRevision(ContentEntityInterface $en } /** @var \Drupal\content_moderation\Entity\ContentModerationStateInterface $content_moderation_state */ - $content_moderation_state = $content_moderation_storage->loadRevision(key($revisions)); + // In the case the content moderation state entity is edited and saved + // separately from the related entity it will trigger saving of the related + // entity and therefor the content moderation state will be loaded here + // again and we have to make sure we will load the unchanged entity and not + // load the already modified one from the static entity cache. + $content_moderation_state = $content_moderation_storage->loadRevisionUnchanged(key($revisions)); if ($entity->getEntityType()->hasKey('langcode')) { $langcode = $entity->language()->getId(); if (!$content_moderation_state->hasTranslation($langcode)) { diff --git a/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php b/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php index 7c331910e2..e146101ed9 100644 --- a/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php +++ b/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php @@ -8,6 +8,7 @@ namespace Drupal\Tests\migrate\Unit\destination; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\ContentEntityStorageInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\migrate\Plugin\MigrationInterface; @@ -48,7 +49,7 @@ protected function setUp() { // Setup mocks to be used when creating a revision destination. $this->migration = $this->prophesize(MigrationInterface::class); - $this->storage = $this->prophesize('\Drupal\Core\Entity\EntityStorageInterface'); + $this->storage = $this->prophesize(ContentEntityStorageInterface::class); $entity_type = $this->prophesize(EntityTypeInterface::class); $entity_type->getSingularLabel()->willReturn('crazy'); @@ -67,7 +68,7 @@ protected function setUp() { public function testGetEntityDestinationValues() { $destination = $this->getEntityRevisionDestination([]); // Return a dummy because we don't care what gets called. - $entity = $this->prophesize('\Drupal\Core\Entity\EntityInterface') + $entity = $this->prophesize(ContentEntityInterface::class) ->willImplement('\Drupal\Core\Entity\RevisionableInterface'); // Assert that the first ID from the destination values is used to load the // entity. diff --git a/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php b/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php index 9f9a114924..77c5cd8352 100644 --- a/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php +++ b/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php @@ -91,6 +91,16 @@ protected function createEntity() { return $node; } + /** + * {@inheritdoc} + */ + protected function createAnotherEntity(array $values = []) { + // Ensure that the duplicate entity will not inherit the path alias of the + // main entity. + $values['path'] = NULL; + return parent::createAnotherEntity($values); + } + /** * {@inheritdoc} */ diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 76f0139be7..870c074d25 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -252,15 +252,21 @@ public function setUp() { /** * Creates another entity to be tested. * + * @param array $values + * (optional) An array of values to set, keyed by field name. + * * @return \Drupal\Core\Entity\EntityInterface * Another entity based on $this->entity. */ - protected function createAnotherEntity() { + protected function createAnotherEntity(array $values = []) { $entity = $this->entity->createDuplicate(); $label_key = $entity->getEntityType()->getKey('label'); - if ($label_key) { + if ($label_key && !isset($values[$label_key])) { $entity->set($label_key, $entity->label() . '_dupe'); } + foreach ($values as $name => $value) { + $entity->set($name, $value); + } $entity->save(); return $entity; } diff --git a/core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php b/core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php index d8fe60e40c..1a0cb1d1a8 100644 --- a/core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php +++ b/core/modules/user/tests/src/Functional/Rest/UserResourceTestBase.php @@ -82,12 +82,9 @@ protected function createEntity() { /** * {@inheritdoc} */ - protected function createAnotherEntity() { - /** @var \Drupal\user\UserInterface $user */ - $user = $this->entity->createDuplicate(); - $user->setUsername($user->label() . '_dupe'); - $user->save(); - return $user; + protected function createAnotherEntity(array $values = []) { + $values['name'] = $this->entity->label() . '_dupe'; + return parent::createAnotherEntity($values); } /** diff --git a/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php index 60e11f21c9..4d336c7e31 100644 --- a/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php @@ -2,8 +2,8 @@ namespace Drupal\Tests\views\Unit\Plugin\query; +use Drupal\Core\Entity\ContentEntityStorageInterface; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityType; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Messenger\MessengerInterface; @@ -218,8 +218,8 @@ protected function setupEntityTypes($entities_by_type = [], $entity_revisions_by // Setup the loading of entities and entity revisions. $entity_storages = [ - 'first' => $this->prophesize(EntityStorageInterface::class), - 'second' => $this->prophesize(EntityStorageInterface::class), + 'first' => $this->prophesize(ContentEntityStorageInterface::class), + 'second' => $this->prophesize(ContentEntityStorageInterface::class), ]; foreach ($entities_by_type as $entity_type_id => $entities) { diff --git a/core/modules/workspace/src/EntityQuery/QueryTrait.php b/core/modules/workspace/src/EntityQuery/QueryTrait.php index 5d34686d4e..c9e32c7f24 100644 --- a/core/modules/workspace/src/EntityQuery/QueryTrait.php +++ b/core/modules/workspace/src/EntityQuery/QueryTrait.php @@ -51,6 +51,14 @@ public function prepare() { if ($this->allRevisions) { return $this; } + // When a revisionable entity is loaded the storage load method will execute + // an entity query to retrieve the default revision ID. In this case we + // should prevent triggering an entity load again, otherwise an endless loop + // will be caused. + // @see \Drupal\Core\Entity\ContentEntityStorageBase::load(). + if ($this->hasTag('entity_load')) { + return $this; + } // Only alter the query if the active workspace is not the default one and // the entity type is supported. diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php index 19943abb8e..5f74f2843d 100644 --- a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php +++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php @@ -77,12 +77,10 @@ protected function createEntity() { /** * {@inheritdoc} */ - protected function createAnotherEntity() { - $workspace = $this->entity->createDuplicate(); - $workspace->id = 'layla_dupe'; - $workspace->label = 'Layla_dupe'; - $workspace->save(); - return $workspace; + protected function createAnotherEntity(array $values = []) { + $values['id'] = 'layla_dupe'; + $values['label'] = 'Layla_dupe'; + return parent::createAnotherEntity($values); } /** diff --git a/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityStaticCacheTest.php b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityStaticCacheTest.php new file mode 100644 index 0000000000..b13d58cc16 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/ContentEntityStaticCacheTest.php @@ -0,0 +1,277 @@ +installEntitySchema('user'); + $this->installEntitySchema($this->nonRevEntityTypeId); + $this->installEntitySchema($this->revEntityTypeId); + } + + /** + * Tests the static cache when loading content entities. + */ + public function testEntityLoad() { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage($this->revEntityTypeId); + + $rev_entity_type = $entity_type_manager->getDefinition($this->revEntityTypeId); + $this->assertTrue($rev_entity_type->isStaticallyCacheable()); + $this->assertTrue($rev_entity_type->isPersistentlyCacheable()); + $this->assertTrue($rev_entity_type->isRevisionable()); + + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->create(); + $entity->save(); + + $non_default_rev_id = $entity->getRevisionId(); + $entity->setNewRevision(); + $entity->save(); + + // Tests the three static cache rules for entity loading: + // 1. Loading an entity multiple times by its ID returns always the same + // entity object reference. + // 2. Loading an entity by its ID and by its default revision ID returns + // always the same entity object reference. + // 3. Loading an entity multiple times by its revision ID returns always the + // same entity object reference. + $this->assertSame($storage->load($entity->id()), $storage->load($entity->id())); + $this->assertSame($storage->load($entity->id()), $storage->loadRevision($entity->getRevisionId())); + $this->assertSame($storage->loadRevision($non_default_rev_id), $storage->loadRevision($non_default_rev_id)); + + // Test that after resetting the entity cache then different object + // references will be returned. + $entity = $storage->load($entity->id()); + $entity_default_revision = $storage->loadRevision($entity->getRevisionId()); + $entity_non_default_revision = $storage->loadRevision($non_default_rev_id); + $storage->resetCache(); + $this->assertNotSame($entity, $storage->load($entity->id())); + $this->assertNotSame($entity_default_revision, $storage->loadRevision($entity->getRevisionId())); + $this->assertNotSame($entity_non_default_revision, $storage->loadRevision($non_default_rev_id)); + + // Tests that the behavior for the three rules remains unchanged after + // resetting the entity cache. + $this->assertSame($storage->load($entity->id()), $storage->load($entity->id())); + $this->assertSame($storage->load($entity->id()), $storage->loadRevision($entity->getRevisionId())); + $this->assertSame($storage->loadRevision($non_default_rev_id), $storage->loadRevision($non_default_rev_id)); + + // Tests that after when first loading the revision and then the entity the + // references will still be the same. + $storage->resetCache(); + $this->assertSame($storage->loadRevision($entity->getRevisionId()), $storage->load($entity->id())); + } + + /** + * Tests that on loading unchanged entity a new object reference is returned. + */ + public function testLoadUnchanged() { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + + $non_rev_entity_type = $entity_type_manager->getDefinition($this->nonRevEntityTypeId); + $this->assertTrue($non_rev_entity_type->isStaticallyCacheable()); + $this->assertTrue($non_rev_entity_type->isPersistentlyCacheable()); + $this->doTestLoadUnchanged($this->nonRevEntityTypeId); + + $rev_entity_type = $entity_type_manager->getDefinition($this->revEntityTypeId); + $this->assertTrue($rev_entity_type->isStaticallyCacheable()); + $this->assertTrue($rev_entity_type->isPersistentlyCacheable()); + $this->assertTrue($rev_entity_type->isRevisionable()); + $this->doTestLoadUnchanged($this->revEntityTypeId); + } + + /** + * Helper method for ::testLoadUnchanged(). + * + * For revisionable entities both the loadUnchanged and loadRevisionUnchanged + * storage methods are tested and for non-revisionable entities only the + * loadUnchanged storage method is tested. + * + * @param string $entity_type_id + * The entity type ID to test Storage::loadUnchanged() with. + */ + protected function doTestLoadUnchanged($entity_type_id) { + foreach ([FALSE, TRUE] as $invalidate_entity_cache) { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + $entity_type = $entity_type_manager->getDefinition($entity_type_id); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage($entity_type_id); + + $entity = $storage->create(); + $entity->save(); + + $entity = $storage->load($entity->id()); + // Invalidating the entity cache will lead to not retrieving the entity + // from the persistent entity cache. This simulates e.g. a behavior where + // in an entity insert hook a field config is created and saved and then + // the cache tag "entity_field_info" will be invalidated leading to + // invalidating the entities in the entity cache, which will prevent + // loadUnchanged from retrieving the entity from the persistent cache, + // which will test that the static entity cache has been reset properly, + // otherwise if not then the same entity object reference will be + // returned. + if ($invalidate_entity_cache) { + Cache::invalidateTags(['entity_field_info']); + } + $unchanged = $storage->loadUnchanged($entity->id()); + $message = $invalidate_entity_cache ? 'loadUnchanged returns a different entity object reference when the entity cache is invalidated before that.' : 'loadUnchanged returns a different entity object reference when the entity cache is not invalidated before that.'; + $this->assertNotSame($entity, $unchanged, $message); + + // For revisionable entities test the same way the + // Storage::loadRevisionUnchanged method as well. + if ($entity_type->isRevisionable()) { + $entity = $storage->loadRevision($entity->getRevisionId()); + if ($invalidate_entity_cache) { + Cache::invalidateTags(['entity_field_info']); + } + $unchanged = $storage->loadRevisionUnchanged($entity->getRevisionId()); + $message = $invalidate_entity_cache ? 'loadRevisionUnchanged returns a different entity object reference when the entity cache is invalidated before that.' : 'loadRevisionUnchanged returns a different entity object reference when the entity cache is not invalidated before that.'; + $this->assertNotSame($entity, $unchanged, $message); + } + } + } + + /** + * Tests loading a cached revision after a non-rev field has been changed. + */ + public function testCacheNonRevField() { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage($this->revEntityTypeId); + + $rev_entity_type = $entity_type_manager->getDefinition($this->revEntityTypeId); + $this->assertTrue($rev_entity_type->isStaticallyCacheable()); + $this->assertTrue($rev_entity_type->isPersistentlyCacheable()); + $this->assertTrue($rev_entity_type->isRevisionable()); + + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->create(); + $entity->set('non_rev_field', 'a'); + $entity->save(); + $non_default_first_rev_id = $entity->getRevisionId(); + + $entity->set('non_rev_field', 'b'); + $entity->setNewRevision(); + $entity->save(); + $non_default_second_rev_id = $entity->getRevisionId(); + + // Load the entity by the revision ID so that it gets cached into the + // persistent cache. + $storage->loadRevision($non_default_second_rev_id); + + // Create a new revision based on the first one, which will leave the second + // revision in the persistent cache - i.e. when saving a revision only the + // revision it originates from will be deleted from the persistent cache. + $entity = $storage->loadRevision($non_default_first_rev_id); + $entity->set('non_rev_field', 'c'); + $entity->setNewRevision(); + // Fields in the base table are updated only when saving a default revision. + // As we've picked up an old revision we have to explicitly declare it as + // default before saving it. + $entity->isDefaultRevision(TRUE); + $entity->save(); + $default_rev_id = $entity->getRevisionId(); + + // Ensure that the middle non-default revision will contain the latest + // value of the non-revisionable field. + $entity = $storage->loadRevision($non_default_second_rev_id); + $this->assertEquals('c', $entity->get('non_rev_field')->value); + + // Ensure that any other revisions contain the latest value of the + // non-revisionable field. + $entity = $storage->loadRevision($non_default_first_rev_id); + $this->assertEquals('c', $entity->get('non_rev_field')->value); + + $entity = $storage->loadRevision($default_rev_id); + $this->assertEquals('c', $entity->get('non_rev_field')->value); + + $entity = $storage->load($entity->id()); + $this->assertEquals('c', $entity->get('non_rev_field')->value); + } + + /** + * Tests deleting an entity or an entity revision. + */ + public function testDelete() { + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage($this->revEntityTypeId); + + $rev_entity_type = $entity_type_manager->getDefinition($this->revEntityTypeId); + $this->assertTrue($rev_entity_type->isStaticallyCacheable()); + $this->assertTrue($rev_entity_type->isPersistentlyCacheable()); + $this->assertTrue($rev_entity_type->isRevisionable()); + + // Create an entity with three revisions by ensuring that each of the + // revisions remains in the persistent entity revision cache. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->create(); + $entity->save(); + $first_rev_id = $entity->getRevisionId(); + $entity = $storage->loadRevision($first_rev_id); + + $entity->setNewRevision(); + $entity->save(); + $second_rev_id = $entity->getRevisionId(); + $entity = $storage->loadRevision($second_rev_id); + + $entity->setNewRevision(); + $entity->save(); + $third_rev_id = $entity->getRevisionId(); + + + // Delete the first revision and ensure that it cannot be loaded. + $storage->deleteRevision($first_rev_id); + $this->assertNull($storage->loadRevision($first_rev_id)); + + // Delete the entity and ensure that no revision can be loaded. + $entity->delete(); + $this->assertNull($storage->loadRevision($first_rev_id)); + $this->assertNull($storage->loadRevision($second_rev_id)); + $this->assertNull($storage->loadRevision($third_rev_id)); + } + +} diff --git a/core/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php b/core/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php index 23ac69fd7f..e55f2581c8 100644 --- a/core/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php +++ b/core/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php @@ -2,9 +2,9 @@ namespace Drupal\Tests\Core\ParamConverter; -use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityRepositoryInterface; -use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\ContentEntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\ParamConverter\EntityRevisionParamConverter; use Drupal\Tests\UnitTestCase; @@ -65,8 +65,8 @@ public function testApplyingRoute() { * @covers ::convert */ public function testConvert() { - $entity = $this->prophesize(EntityInterface::class)->reveal(); - $storage = $this->prophesize(EntityStorageInterface::class); + $entity = $this->prophesize(ContentEntityInterface::class)->reveal(); + $storage = $this->prophesize(ContentEntityStorageInterface::class); $storage->loadRevision(1)->willReturn($entity); $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);