diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php index 62fb13e..6f0ac8f 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityStorage.php @@ -135,6 +135,13 @@ public function loadRevision($revision_id) { /** * {@inheritdoc} */ + public function loadMultipleRevisions(array $revision_ids) { + return []; + } + + /** + * {@inheritdoc} + */ public function deleteRevision($revision_id) { return NULL; } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityNullStorage.php b/core/lib/Drupal/Core/Entity/ContentEntityNullStorage.php index 3dc00c9..94efc11 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityNullStorage.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityNullStorage.php @@ -41,6 +41,13 @@ public function loadRevision($revision_id) { /** * {@inheritdoc} */ + public function loadMultipleRevisions(array $revision_ids) { + return []; + } + + /** + * {@inheritdoc} + */ public function deleteRevision($revision_id) { } diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index 874a341..dc63033 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php @@ -244,27 +244,38 @@ public function finalizePurge(FieldStorageDefinitionInterface $storage_definitio * {@inheritdoc} */ public function loadRevision($revision_id) { - $revision = $this->doLoadRevisionFieldItems($revision_id); + $revisions = $this->loadMultipleRevisions([$revision_id]); - if ($revision) { + return isset($revisions[$revision_id]) ? $revisions[$revision_id] : NULL; + } + + /** + * {@inheritdoc} + */ + public function loadMultipleRevisions(array $revision_ids) { + $revisions = $this->doLoadRevisionFieldItems($revision_ids); + + // The hooks are invoked keyed by entity ID so we have invoke them for each + // revision. + foreach ($revisions as $revision) { $entities = [$revision->id() => $revision]; $this->invokeStorageLoadHook($entities); $this->postLoad($entities); } - return $revision; + return $revisions; } /** * Actually loads revision field item values from the storage. * - * @param int|string $revision_id - * The revision identifier. + * @param array $revision_ids + * An array of revision identifiers. * - * @return \Drupal\Core\Entity\EntityInterface|null - * The specified entity revision or NULL if not found. + * @return \Drupal\Core\Entity\EntityInterface[] + * The specified entity revisions or an empty array if none are found. */ - abstract protected function doLoadRevisionFieldItems($revision_id); + abstract protected function doLoadRevisionFieldItems($revision_ids); /** * {@inheritdoc} @@ -597,22 +608,24 @@ protected function populateAffectedRevisionTranslations(ContentEntityInterface $ } /** - * Ensures integer entity IDs are valid. + * Ensures integer entity key values are valid. * * The identifier sanitization provided by this method has been introduced * as Drupal used to rely on the database to facilitate this, which worked * correctly with MySQL but led to errors with other DBMS such as PostgreSQL. * * @param array $ids - * The entity IDs to verify. + * The entity key values to verify. + * @param string $entity_key + * (optional) The entity key to sanitise values for. Defaults to 'id'. * * @return array - * The sanitized list of entity IDs. + * The sanitized list of entity key values. */ - protected function cleanIds(array $ids) { + protected function cleanIds(array $ids, $entity_key = 'id') { $definitions = $this->entityManager->getBaseFieldDefinitions($this->entityTypeId); - $id_definition = $definitions[$this->entityType->getKey('id')]; - if ($id_definition->getType() == 'integer') { + $field_name = $this->entityType->getKey($entity_key); + if ($field_name && $definitions[$field_name]->getType() == 'integer') { $ids = array_filter($ids, function ($id) { return is_numeric($id) && $id == (int) $id; }); diff --git a/core/lib/Drupal/Core/Entity/EntityStorageInterface.php b/core/lib/Drupal/Core/Entity/EntityStorageInterface.php index 9f50674..4b5b837 100644 --- a/core/lib/Drupal/Core/Entity/EntityStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityStorageInterface.php @@ -83,6 +83,17 @@ public function loadUnchanged($id); public function loadRevision($revision_id); /** + * Loads multiple entity revisions. + * + * @param array $revision_ids + * An array of revision IDs to load. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * The specified entity revisions or an empty array if none found. + */ + public function loadMultipleRevisions(array $revision_ids); + + /** * Delete a specific entity revision. * * A revision can only be deleted if it's not the currently active one. diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php index cd2f26e..fd542d9 100644 --- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueEntityStorage.php @@ -133,6 +133,13 @@ public function loadRevision($revision_id) { /** * {@inheritdoc} */ + public function loadMultipleRevisions(array $revision_ids) { + return []; + } + + /** + * {@inheritdoc} + */ public function deleteRevision($revision_id) { return NULL; } diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 275e539..c745b1d 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -468,9 +468,11 @@ protected function getFromStorage(array $ids = NULL) { * Maps from storage records to entity objects, and attaches fields. * * @param array $records - * Associative array of query results, keyed on the entity ID. + * Associative array of query results, keyed on the entity ID or revision + * ID. * @param bool $load_from_revision - * Flag to indicate whether revisions should be loaded or not. + * (optional) Flag to indicate whether revisions should be loaded or not. + * Defaults to FALSE. * * @return array * An array of entity objects implementing the EntityInterface. @@ -505,7 +507,7 @@ protected function mapFromStorageRecords(array $records, $load_from_revision = F $translations = array_fill_keys(array_keys($values), []); // Load values from shared and dedicated tables. - $this->loadFromSharedTables($values, $translations); + $this->loadFromSharedTables($values, $translations, $load_from_revision); $this->loadFromDedicatedTables($values, $load_from_revision); $entities = []; @@ -522,11 +524,15 @@ protected function mapFromStorageRecords(array $records, $load_from_revision = F * Loads values for fields stored in the shared data tables. * * @param array &$values - * Associative array of entities values, keyed on the entity ID. + * Associative array of entities values, keyed on the entity ID or the + * revision ID. * @param array &$translations * List of translations, keyed on the entity ID. + * @param bool $load_from_revision + * Flag to indicate whether revisions should be loaded or not. */ - protected function loadFromSharedTables(array &$values, array &$translations) { + protected function loadFromSharedTables(array &$values, array &$translations, $load_from_revision) { + $record_key = !$load_from_revision ? $this->idKey : $this->revisionKey; if ($this->dataTable) { // If a revision table is available, we need all the properties of the // latest revision. Otherwise we fall back to the data table. @@ -534,8 +540,8 @@ protected function loadFromSharedTables(array &$values, array &$translations) { $alias = $this->revisionDataTable ? 'revision' : 'data'; $query = $this->database->select($table, $alias, ['fetch' => \PDO::FETCH_ASSOC]) ->fields($alias) - ->condition($alias . '.' . $this->idKey, array_keys($values), 'IN') - ->orderBy($alias . '.' . $this->idKey); + ->condition($alias . '.' . $record_key, array_keys($values), 'IN') + ->orderBy($alias . '.' . $record_key); $table_mapping = $this->getTableMapping(); if ($this->revisionDataTable) { @@ -580,7 +586,7 @@ protected function loadFromSharedTables(array &$values, array &$translations) { $result = $query->execute(); foreach ($result as $row) { - $id = $row[$this->idKey]; + $id = $row[$record_key]; // Field values in default language are stored with // LanguageInterface::LANGCODE_DEFAULT as key. @@ -607,20 +613,33 @@ protected function loadFromSharedTables(array &$values, array &$translations) { /** * {@inheritdoc} */ - protected function doLoadRevisionFieldItems($revision_id) { - $revision = NULL; + protected function doLoadRevisionFieldItems($revision_ids) { + // Support the case when a single revision ID is passed in. + $load_single = FALSE; + if (!is_array($revision_ids)) { + @trigger_error('Passing a single revision ID to "\Drupal\Core\Entity\ContentEntityStorageBase::doLoadRevisionFieldItems()" is deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. An array of revision IDs should be given instead.', E_USER_DEPRECATED); + $revision_ids = (array) $revision_ids; + $load_single = TRUE; + } + + $revisions = []; - // Build and execute the query. - $query_result = $this->buildQuery([], $revision_id)->execute(); - $records = $query_result->fetchAllAssoc($this->idKey); + // 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'); - if (!empty($records)) { - // Convert the raw records to entity objects. - $entities = $this->mapFromStorageRecords($records, TRUE); - $revision = reset($entities) ?: NULL; + if (!empty($revision_ids)) { + // Build and execute the query. + $query_result = $this->buildQuery(NULL, $revision_ids)->execute(); + $records = $query_result->fetchAllAssoc($this->revisionKey); + + // Map the loaded records into entity objects and according fields. + if ($records) { + $revisions = $this->mapFromStorageRecords($records, TRUE); + } } - return $revision; + return $load_single ? (!empty($revisions) ? reset($revisions) : NULL) : $revisions; } /** @@ -676,20 +695,20 @@ protected function buildPropertyQuery(QueryInterface $entity_query, array $value * * @param array|null $ids * An array of entity IDs, or NULL to load all entities. - * @param $revision_id - * The ID of the revision to load, or FALSE if this query is asking for the + * @param array|bool $revision_ids + * The IDs of the revision to load, or FALSE if this query is asking for the * most current revision(s). * * @return \Drupal\Core\Database\Query\Select * A SelectQuery object for loading the entity. */ - protected function buildQuery($ids, $revision_id = FALSE) { + protected function buildQuery($ids, $revision_ids = FALSE) { $query = $this->database->select($this->entityType->getBaseTable(), 'base'); $query->addTag($this->entityTypeId . '_load_multiple'); - if ($revision_id) { - $query->join($this->revisionTable, 'revision', "revision.{$this->idKey} = base.{$this->idKey} AND revision.{$this->revisionKey} = :revisionId", [':revisionId' => $revision_id]); + if ($revision_ids) { + $query->join($this->revisionTable, 'revision', "revision.{$this->idKey} = base.{$this->idKey} AND revision.{$this->revisionKey} IN (:revisionIds[])", [':revisionIds[]' => $revision_ids]); } elseif ($this->revisionTable) { $query->join($this->revisionTable, 'revision', "revision.{$this->revisionKey} = base.{$this->revisionKey}"); @@ -1113,8 +1132,7 @@ protected function getQueryServiceName() { * @param array &$values * An array of values keyed by entity ID. * @param bool $load_from_revision - * (optional) Flag to indicate whether revisions should be loaded or not, - * defaults to FALSE. + * Flag to indicate whether revisions should be loaded or not. */ protected function loadFromDedicatedTables(array &$values, $load_from_revision) { if (empty($values)) { @@ -1166,21 +1184,22 @@ protected function loadFromDedicatedTables(array &$values, $load_from_revision) foreach ($results as $row) { $bundle = $row->bundle; + $value_key = !$load_from_revision ? $row->entity_id : $row->revision_id; // Field values in default language are stored with // LanguageInterface::LANGCODE_DEFAULT as key. $langcode = LanguageInterface::LANGCODE_DEFAULT; - if ($this->langcodeKey && isset($default_langcodes[$row->entity_id]) && $row->langcode != $default_langcodes[$row->entity_id]) { + if ($this->langcodeKey && isset($default_langcodes[$value_key]) && $row->langcode != $default_langcodes[$value_key]) { $langcode = $row->langcode; } - if (!isset($values[$row->entity_id][$field_name][$langcode])) { - $values[$row->entity_id][$field_name][$langcode] = []; + if (!isset($values[$value_key][$field_name][$langcode])) { + $values[$value_key][$field_name][$langcode] = []; } // Ensure that records for non-translatable fields having invalid // languages are skipped. if ($langcode == LanguageInterface::LANGCODE_DEFAULT || $definitions[$bundle][$field_name]->isTranslatable()) { - if ($storage_definition->getCardinality() == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED || count($values[$row->entity_id][$field_name][$langcode]) < $storage_definition->getCardinality()) { + if ($storage_definition->getCardinality() == FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED || count($values[$value_key][$field_name][$langcode]) < $storage_definition->getCardinality()) { $item = []; // For each column declared by the field, populate the item from the // prefixed database column. @@ -1191,7 +1210,7 @@ protected function loadFromDedicatedTables(array &$values, $load_from_revision) } // Add the item to the field values for the entity. - $values[$row->entity_id][$field_name][$langcode][] = $item; + $values[$value_key][$field_name][$langcode][] = $item; } } } diff --git a/core/modules/system/tests/src/Functional/Entity/EntityRevisionsTest.php b/core/modules/system/tests/src/Functional/Entity/EntityRevisionsTest.php index caf5fa2..8b544c4 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityRevisionsTest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityRevisionsTest.php @@ -3,6 +3,8 @@ namespace Drupal\Tests\system\Functional\Entity; use Drupal\entity_test\Entity\EntityTestMulRev; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\BrowserTestBase; @@ -60,20 +62,42 @@ public function testRevisions() { * The entity type to run the tests with. */ protected function runRevisionsTests($entity_type) { + // Create a translatable test field. + $field_storage = FieldStorageConfig::create([ + 'entity_type' => $entity_type, + 'field_name' => 'translatable_test_field', + 'type' => 'text', + 'cardinality' => 2, + ]); + $field_storage->save(); + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'label' => $this->randomMachineName(), + 'bundle' => $entity_type, + 'translatable' => TRUE, + ]); + $field->save(); + + entity_get_form_display($entity_type, $entity_type, 'default') + ->setComponent('translatable_test_field') + ->save(); + + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = \Drupal::entityTypeManager()->getStorage($entity_type); // Create initial entity. - $entity = $this->container->get('entity_type.manager') - ->getStorage($entity_type) + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage ->create([ 'name' => 'foo', 'user_id' => $this->webUser->id(), ]); - $entity->field_test_text->value = 'bar'; + $entity->translatable_test_field->value = 'bar'; + $entity->addTranslation('de', ['name' => 'foo - de']); $entity->save(); - $names = []; - $texts = []; - $created = []; + $values = []; $revision_ids = []; // Create three revisions. @@ -81,45 +105,74 @@ protected function runRevisionsTests($entity_type) { for ($i = 0; $i < $revision_count; $i++) { $legacy_revision_id = $entity->revision_id->value; $legacy_name = $entity->name->value; - $legacy_text = $entity->field_test_text->value; + $legacy_text = $entity->translatable_test_field->value; - $entity = $this->container->get('entity_type.manager') - ->getStorage($entity_type)->load($entity->id->value); + $entity = $storage->load($entity->id->value); $entity->setNewRevision(TRUE); - $names[] = $entity->name->value = $this->randomMachineName(32); - $texts[] = $entity->field_test_text->value = $this->randomMachineName(32); - $created[] = $entity->created->value = time() + $i + 1; + $values['en'][$i] = [ + 'name' => $this->randomMachineName(32), + 'translatable_test_field' => [ + $this->randomMachineName(32), + $this->randomMachineName(32), + ], + 'created' => time() + $i + 1, + ]; + $entity->set('name', $values['en'][$i]['name']); + $entity->set('translatable_test_field', $values['en'][$i]['translatable_test_field']); + $entity->set('created', $values['en'][$i]['created']); $entity->save(); $revision_ids[] = $entity->revision_id->value; + // Add some values for a translation of this revision. + if ($entity->getEntityType()->isTranslatable()) { + $values['de'][$i] = [ + 'name' => $this->randomMachineName(32), + 'translatable_test_field' => [ + $this->randomMachineName(32), + $this->randomMachineName(32), + ], + ]; + $translation = $entity->getTranslation('de'); + $translation->set('name', $values['de'][$i]['name']); + $translation->set('translatable_test_field', $values['de'][$i]['translatable_test_field']); + $translation->save(); + } + // Check that the fields and properties contain new content. $this->assertTrue($entity->revision_id->value > $legacy_revision_id, format_string('%entity_type: Revision ID changed.', ['%entity_type' => $entity_type])); $this->assertNotEqual($entity->name->value, $legacy_name, format_string('%entity_type: Name changed.', ['%entity_type' => $entity_type])); - $this->assertNotEqual($entity->field_test_text->value, $legacy_text, format_string('%entity_type: Text changed.', ['%entity_type' => $entity_type])); + $this->assertNotEqual($entity->translatable_test_field->value, $legacy_text, format_string('%entity_type: Text changed.', ['%entity_type' => $entity_type])); } - $storage = $this->container->get('entity_type.manager')->getStorage($entity_type); + $revisions = $storage->loadMultipleRevisions($revision_ids); for ($i = 0; $i < $revision_count; $i++) { // Load specific revision. - $entity_revision = $storage->loadRevision($revision_ids[$i]); + $entity_revision = $revisions[$revision_ids[$i]]; // Check if properties and fields contain the revision specific content. $this->assertEqual($entity_revision->revision_id->value, $revision_ids[$i], format_string('%entity_type: Revision ID matches.', ['%entity_type' => $entity_type])); - $this->assertEqual($entity_revision->name->value, $names[$i], format_string('%entity_type: Name matches.', ['%entity_type' => $entity_type])); - $this->assertEqual($entity_revision->field_test_text->value, $texts[$i], format_string('%entity_type: Text matches.', ['%entity_type' => $entity_type])); + $this->assertEqual($entity_revision->name->value, $values['en'][$i]['name'], format_string('%entity_type: Name matches.', ['%entity_type' => $entity_type])); + $this->assertEqual($entity_revision->translatable_test_field[0]->value, $values['en'][$i]['translatable_test_field'][0], format_string('%entity_type: Text matches.', ['%entity_type' => $entity_type])); + $this->assertEqual($entity_revision->translatable_test_field[1]->value, $values['en'][$i]['translatable_test_field'][1], format_string('%entity_type: Text matches.', ['%entity_type' => $entity_type])); + + // Check the translated values. + if ($entity->getEntityType()->isTranslatable()) { + $revision_translation = $entity_revision->getTranslation('de'); + $this->assertEqual($revision_translation->name->value, $values['de'][$i]['name'], format_string('%entity_type: Name matches.', ['%entity_type' => $entity_type])); + $this->assertEqual($revision_translation->translatable_test_field[0]->value, $values['de'][$i]['translatable_test_field'][0], format_string('%entity_type: Text matches.', ['%entity_type' => $entity_type])); + $this->assertEqual($revision_translation->translatable_test_field[1]->value, $values['de'][$i]['translatable_test_field'][1], format_string('%entity_type: Text matches.', ['%entity_type' => $entity_type])); + } // Check non-revisioned values are loaded. $this->assertTrue(isset($entity_revision->created->value), format_string('%entity_type: Non-revisioned field is loaded.', ['%entity_type' => $entity_type])); - $this->assertEqual($entity_revision->created->value, $created[2], format_string('%entity_type: Non-revisioned field value is the same between revisions.', ['%entity_type' => $entity_type])); + $this->assertEqual($entity_revision->created->value, $values['en'][2]['created'], format_string('%entity_type: Non-revisioned field value is the same between revisions.', ['%entity_type' => $entity_type])); } // Confirm the correct revision text appears in the edit form. - $entity = $this->container->get('entity_type.manager') - ->getStorage($entity_type) - ->load($entity->id->value); + $entity = $storage->load($entity->id->value); $this->drupalGet($entity_type . '/manage/' . $entity->id->value . '/edit'); $this->assertFieldById('edit-name-0-value', $entity->name->value, format_string('%entity_type: Name matches in UI.', ['%entity_type' => $entity_type])); - $this->assertFieldById('edit-field-test-text-0-value', $entity->field_test_text->value, format_string('%entity_type: Text matches in UI.', ['%entity_type' => $entity_type])); + $this->assertFieldById('edit-translatable-test-field-0-value', $entity->translatable_test_field->value, format_string('%entity_type: Text matches in UI.', ['%entity_type' => $entity_type])); } /** diff --git a/core/tests/Drupal/Tests/Listeners/DeprecationListener.php b/core/tests/Drupal/Tests/Listeners/DeprecationListener.php index 3673376..7030353 100644 --- a/core/tests/Drupal/Tests/Listeners/DeprecationListener.php +++ b/core/tests/Drupal/Tests/Listeners/DeprecationListener.php @@ -111,6 +111,7 @@ public static function getSkippedDeprecations() { 'The Drupal\migrate_drupal\Plugin\migrate\source\d6\i18nVariable is deprecated in Drupal 8.4.0 and will be removed before Drupal 9.0.0. Instead, use Drupal\migrate_drupal\Plugin\migrate\source\d6\VariableTranslation', 'Implicit cacheability metadata bubbling (onto the global render context) in normalizers is deprecated since Drupal 8.5.0 and will be removed in Drupal 9.0.0. Use the "cacheability" serialization context instead, for explicit cacheability metadata bubbling. See https://www.drupal.org/node/2918937', 'Automatically creating the first item for computed fields is deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. Use \Drupal\Core\TypedData\ComputedItemListTrait instead.', + 'Passing a single revision ID to "\Drupal\Core\Entity\ContentEntityStorageBase::doLoadRevisionFieldItems()" is deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. An array of revision IDs should be given instead.', ]; }