diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index d5872eb..3116436 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. * * @var \Drupal\Core\Entity\EntityManagerInterface @@ -215,13 +222,81 @@ public function finalizePurge(FieldStorageDefinitionInterface $storage_definitio /** * {@inheritdoc} */ + public function load($id) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = parent::load($id); + + // If there is already an entry in the static entity revisions cache we + // return a reference to the already loaded entity object. + // + // @todo find a better way to determine the default revision id without + // having to load the entity object from the storage, then check if there + // is already an entry in the static entity revisions cache for it and if + // not load the entity from the storage. + if ($entity && $this->entityType->isStaticallyCacheable() && $this->entityType->isRevisionable()) { + // If the default revision is not present in the static entity revision + // cache we add it and return the entity. + if (!$this->getFromStaticEntityRevisionCache($entity->getRevisionId())) { + $this->setStaticEntityRevisionCache($entity->getRevisionId(), $entity); + } + return $this->getFromStaticEntityRevisionCache($entity->getRevisionId()); + } + + return $entity; + } + + /** + * {@inheritdoc} + */ public function loadRevision($revision_id) { - $revision = $this->doLoadRevisionFieldItems($revision_id); + $revision = NULL; - if ($revision) { + if (!isset($this->ignoreStaticCache)) { + if ($this->entityType->isStaticallyCacheable()) { + $revision = $this->getFromStaticEntityRevisionCache($revision_id); + } + + if (!$revision && $this->entityType->isPersistentlyCacheable()) { + $revision = $this->getRevisionFromPersistentCache($revision_id); + if ($revision) { + $entities = [$revision->id() => $revision]; + $this->postLoad($entities); + $this->setStaticEntityRevisionCache($revision_id, $revision); + if ($revision->isDefaultRevision()) { + $this->setStaticCache($entities); + } + } + } + } + + return $revision ?: $this->doLoadRevision($revision_id); + } + + /** + * Load a specific entity revision. + * + * @param int|string $revision_id + * The revision id. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * The specified entity revision or NULL if not found. + */ + protected function doLoadRevision($revision_id) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + if ($revision = $this->doLoadRevisionFieldItems($revision_id)) { $entities = [$revision->id() => $revision]; $this->invokeStorageLoadHook($entities); + if ($revision->isDefaultRevision()) { + $this->setPersistentCache($entities); + } $this->postLoad($entities); + + if (!isset($this->ignoreStaticCache)) { + if ($revision->isDefaultRevision()) { + $this->setStaticCache($entities); + } + $this->setStaticEntityRevisionCache($revision_id, $revision); + } } return $revision; @@ -308,6 +383,19 @@ protected function doPostSave(EntityInterface $entity, $update) { $this->invokeTranslationHooks($entity); } + if ($this->entityType->isRevisionable()) { + // If a non-default revision has been loaded it has been statically only + // in the entity revision cache, which means that the parent class call + // in ::doPostSave to ::resetCache will not be able to reset the static + // revision cache as it is only calling the function with the entity id, + // but as for this entity id there will be no entity cache entry we'll + // not be able to retrieve the revision id and remove its static cache + // there. + // @todo consider adding an optional entity parameter to ::resetCache, as + // in this case we'll not need this logic here. + $this->resetRevisionCache([$entity->getRevisionId(), $entity->getLoadedRevisionId()]); + } + parent::doPostSave($entity, $update); // The revision is stored, it should no longer be marked as new now. @@ -613,6 +701,44 @@ protected function getFromPersistentCache(array &$ids = NULL) { } /** + * Gets entity from the persistent cache backend by revision id. + * + * @param int|null $revision_id + * If not empty, return the entity that match these revision ID. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * The entity from the persistent cache or NULL if not present. + */ + protected function getRevisionFromPersistentCache($revision_id = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + $revision = NULL; + if (!$this->entityType->isPersistentlyCacheable() || empty($revision_id)) { + return $revision; + } + // Build the list of cache entries to retrieve. + $cid = $this->buildRevisionCacheId($revision_id); + if ($cache = $this->cacheBackend->get($cid)) { + // Get the entity that was found in the cache. + $revision = $cache->data; + } + + // 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 + // $isDefaultRevision property of the entity revision. + if ($revision && $revision->isDefaultRevision() && ($revision_id != $this->getDefaultRevisionId($revision->id()))) { + $revision->isDefaultRevision(FALSE); + } + + if ($revision) { + $entities = [$revision->id() => $revision]; + $this->invokeStorageLoadHook($entities); + $this->postLoad($entities); + } + + return $revision; + } + + /** * Stores entities in the persistent cache backend. * * @param \Drupal\Core\Entity\ContentEntityInterface[] $entities @@ -629,6 +755,9 @@ protected function setPersistentCache($entities) { ); foreach ($entities as $id => $entity) { $this->cacheBackend->set($this->buildCacheId($id), $entity, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + if ($this->entityType->isRevisionable() && $entity->isDefaultRevision()) { + $this->cacheBackend->set($this->buildRevisionCacheId($entity->getRevisionId()), $entity, CacheBackendInterface::CACHE_PERMANENT, $cache_tags); + } } } @@ -677,21 +806,105 @@ public function loadUnchanged($id) { */ public function resetCache(array $ids = NULL) { if ($ids) { - $cids = array(); + $cids = []; + $revision_ids = []; + $is_revisionable = $this->entityType->isRevisionable(); foreach ($ids as $id) { + if ($is_revisionable && isset($this->entities[$id])) { + $original_revision_id = $this->entities[$id]->getLoadedRevisionId(); + $revision_id = $this->entities[$id]->getRevisionId(); + $revision_ids[$original_revision_id] = $original_revision_id; + $revision_ids[$revision_id] = $revision_id; + } unset($this->entities[$id]); $cids[] = $this->buildCacheId($id); } + $this->resetRevisionCache($revision_ids); if ($this->entityType->isPersistentlyCacheable()) { $this->cacheBackend->deleteMultiple($cids); } } else { - $this->entities = array(); + $this->entities = []; + $this->entityRevisions = []; if ($this->entityType->isPersistentlyCacheable()) { - Cache::invalidateTags(array($this->entityTypeId . '_values')); + Cache::invalidateTags([$this->entityTypeId . '_values']); + } + } + } + + /** + * Resets the internal, static entity revision cache. + * + * @param $revision_ids + * The revision cache will be reset for the entities with the given + * revision ids. + */ + protected function resetRevisionCache(array $revision_ids) { + if ($revision_ids) { + $cids = []; + foreach ($revision_ids as $revision_id) { + unset($this->entityRevisions[$revision_id]); + $cids[] = $this->buildRevisionCacheId($revision_id); } + if ($this->entityType->isPersistentlyCacheable()) { + $this->cacheBackend->deleteMultiple($cids); + } + } + } + + /** + * Gets entities from the static entity revision cache. + * + * @param int $revision_id + * The entity revision id, for which the entity should be loaded from the + * static entity revision cache. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|NULL + * The entity from the entity revision cache or NULL if it is not present. + */ + protected function getFromStaticEntityRevisionCache($revision_id) { + $entity = $this->entityType->isStaticallyCacheable() && isset($this->entityRevisions[$revision_id]) ? $this->entityRevisions[$revision_id] : NULL; + // Update the default revision + if ($entity) { + } + return $entity; + } + + /** + * Stores entities in the static entity revision cache. + * + * @param int $revision_id + * The entity revision id, for which the entity should be stored in the + * static entity revision cache. + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity to store in the static entity revision cache. + */ + protected function setStaticEntityRevisionCache($revision_id, ContentEntityInterface $entity) { + if ($this->entityType->isStaticallyCacheable()) { + $this->entityRevisions[$revision_id] = $entity; + } + } + + /** + * {@inheritdoc} + */ + public function loadRevisionUnchanged($revision_id) { + // Load the revision by ignoring the static entity and entity revision + // cache. + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + if ($revision = $this->getRevisionFromPersistentCache($revision_id)) { + $entities = [$revision->id() => $revision]; + $this->postLoad($entities); + } + else { + $this->ignoreStaticCache = TRUE; + $revision = $this->loadRevision($revision_id); + unset($this->ignoreStaticCache); + } + + return $revision; } /** @@ -707,4 +920,38 @@ 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. + * + * @return int|null + * The default revision id. + */ + protected function getDefaultRevisionId($id) { + if ($this->entityType->isRevisionable()) { + $result = $this->getQuery() + ->condition($this->entityType->getKey('id'), $id) + ->execute(); + } + else { + $result = NULL; + } + return $result ? key($result) : NULL; + } + } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php index 47e058d..6874e1f 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php @@ -8,6 +8,52 @@ interface ContentEntityStorageInterface extends EntityStorageInterface { /** + * Loads one entity. + * + * @param mixed $id + * The ID of the entity to load. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * An entity object. NULL if no matching entity is found. + */ + public function load($id); + + /** + * Load a specific entity revision. + * + * @param int|string $revision_id + * The revision id. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|null + * The specified entity revision or NULL if not found. + */ + public function loadRevision($revision_id); + + /** + * Loads an unchanged entity by revision id from the database. + * + * @param mixed $revision_id + * The revision ID of the entity to load. + * + * @return \Drupal\Core\Entity\ContentEntityInterface|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); + + /** + * Delete a specific entity revision. + * + * A revision can only be deleted if it's not the currently active one. + * + * @param int $revision_id + * The revision id. + */ + public function deleteRevision($revision_id); + + /** * Constructs a new entity translation object, without permanently saving it. * * @param \Drupal\Core\Entity\ContentEntityInterface $entity diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php index f583121..d0f1e4e 100644 --- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php @@ -14,7 +14,7 @@ * * @var array */ - protected $entities = array(); + protected $entities = []; /** * Entity type ID for this storage. diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php index c42e7c0..41f9f27 100644 --- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php @@ -13,6 +13,27 @@ class KeyValueContentEntityStorage extends KeyValueEntityStorage implements Cont /** * {@inheritdoc} */ + public function loadRevision($revision_id) { + // @todo + } + + /** + * {@inheritdoc} + */ + public function loadRevisionUnchanged($revision_id) { + // @todo + } + + /** + * {@inheritdoc} + */ + public function deleteRevision($revision_id) { + // @todo + } + + /** + * {@inheritdoc} + */ public function createTranslation(ContentEntityInterface $entity, $langcode, array $values = []) { // @todo Complete the content entity storage implementation in // https://www.drupal.org/node/2618436. diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php index d60dad7..a5033f9 100644 --- a/core/modules/content_moderation/src/Entity/ContentModerationState.php +++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php @@ -163,6 +163,7 @@ public function save() { $related_entity = $related_entity->getTranslation($this->activeLangcode); } $related_entity->moderation_state = $this->moderation_state; + $this->entityTypeManager()->getStorage('content_moderation_state')->resetCache([$this->id()]); return $related_entity->save(); } diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php index b5b5a75..6342b50 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php @@ -191,7 +191,7 @@ public function testMultilingualModeration() { $content_moderation_state->setNewRevision(TRUE); // Revision 7 (en, fr). $content_moderation_state->save(); - $english_node = $this->reloadNode($french_node, $french_node->getRevisionId() + 1); + $english_node = $this->reloadNode($french_node, 7); $this->assertEquals('draft', $english_node->moderation_state->value); $french_node = $this->reloadNode($english_node)->getTranslation('fr'); diff --git a/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php b/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php index 1886914..b093514 100644 --- a/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php +++ b/core/modules/migrate/tests/src/Unit/destination/EntityRevisionTest.php @@ -47,7 +47,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('\Drupal\Core\Entity\ContentEntityStorageInterface'); $this->entityManager = $this->prophesize('\Drupal\Core\Entity\EntityManagerInterface'); $this->fieldTypeManager = $this->prophesize('\Drupal\Core\Field\FieldTypePluginManagerInterface'); } @@ -60,7 +60,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('\Drupal\Core\Entity\ContentEntityInterface') ->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/views/tests/src/Unit/Plugin/query/SqlTest.php b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php index 02a5ce6..c2c5069 100644 --- a/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php @@ -2,6 +2,7 @@ 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; @@ -212,8 +213,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/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php b/core/tests/Drupal/Tests/Core/ParamConverter/EntityRevisionParamConverterTest.php index 23ac69f..e55f258 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);