Commit 0b90895a authored by catch's avatar catch

Issue #1730874 by amateescu, e2thex, indytechcook, disasm, googletorp,...

Issue #1730874 by amateescu, e2thex, indytechcook, disasm, googletorp, hchonov, Berdir: Add support for loading multiple revisions at once
parent b0a4d7e0
......@@ -132,6 +132,13 @@ public function loadRevision($revision_id) {
return NULL;
}
/**
* {@inheritdoc}
*/
public function loadMultipleRevisions(array $revision_ids) {
return [];
}
/**
* {@inheritdoc}
*/
......
......@@ -38,6 +38,13 @@ public function loadRevision($revision_id) {
return NULL;
}
/**
* {@inheritdoc}
*/
public function loadMultipleRevisions(array $revision_ids) {
return [];
}
/**
* {@inheritdoc}
*/
......
......@@ -244,15 +244,37 @@ public function finalizePurge(FieldStorageDefinitionInterface $storage_definitio
* {@inheritdoc}
*/
public function loadRevision($revision_id) {
$revision = $this->doLoadRevisionFieldItems($revision_id);
$revisions = $this->loadMultipleRevisions([$revision_id]);
if ($revision) {
$entities = [$revision->id() => $revision];
return isset($revisions[$revision_id]) ? $revisions[$revision_id] : NULL;
}
/**
* {@inheritdoc}
*/
public function loadMultipleRevisions(array $revision_ids) {
$revisions = $this->doLoadMultipleRevisionsFieldItems($revision_ids);
// 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) {
$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);
}
return $revision;
return $revisions;
}
/**
......@@ -263,9 +285,33 @@ public function loadRevision($revision_id) {
*
* @return \Drupal\Core\Entity\EntityInterface|null
* The specified entity revision or NULL if not found.
*
* @deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0.
* \Drupal\Core\Entity\ContentEntityStorageBase::doLoadMultipleRevisionsFieldItems()
* should be implemented instead.
*
* @see https://www.drupal.org/node/2924915
*/
abstract protected function doLoadRevisionFieldItems($revision_id);
/**
* Actually loads revision field item values from the storage.
*
* @param array $revision_ids
* An array of revision identifiers.
*
* @return \Drupal\Core\Entity\EntityInterface[]
* The specified entity revisions or an empty array if none are found.
*/
protected function doLoadMultipleRevisionsFieldItems($revision_ids) {
$revisions = [];
foreach ($revision_ids as $revision_id) {
$revisions[] = $this->doLoadRevisionFieldItems($revision_id);
}
return $revisions;
}
/**
* {@inheritdoc}
*/
......@@ -604,22 +650,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;
});
......
......@@ -82,6 +82,18 @@ 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[]
* An array of entity revisions keyed by their revision ID, or an empty
* array if none found.
*/
public function loadMultipleRevisions(array $revision_ids);
/**
* Delete a specific entity revision.
*
......
......@@ -130,6 +130,13 @@ public function loadRevision($revision_id) {
return NULL;
}
/**
* {@inheritdoc}
*/
public function loadMultipleRevisions(array $revision_ids) {
return [];
}
/**
* {@inheritdoc}
*/
......
......@@ -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.
......@@ -608,19 +614,35 @@ protected function loadFromSharedTables(array &$values, array &$translations) {
* {@inheritdoc}
*/
protected function doLoadRevisionFieldItems($revision_id) {
$revision = NULL;
@trigger_error('"\Drupal\Core\Entity\ContentEntityStorageBase::doLoadRevisionFieldItems()" is deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. "\Drupal\Core\Entity\ContentEntityStorageBase::doLoadMultipleRevisionsFieldItems()" should be implemented instead. See https://www.drupal.org/node/2924915.', E_USER_DEPRECATED);
// Build and execute the query.
$query_result = $this->buildQuery([], $revision_id)->execute();
$records = $query_result->fetchAllAssoc($this->idKey);
$revisions = $this->doLoadMultipleRevisionsFieldItems([$revision_id]);
return !empty($revisions) ? reset($revisions) : NULL;
}
/**
* {@inheritdoc}
*/
protected function doLoadMultipleRevisionsFieldItems($revision_ids) {
$revisions = [];
if (!empty($records)) {
// Convert the raw records to entity objects.
$entities = $this->mapFromStorageRecords($records, TRUE);
$revision = reset($entities) ?: NULL;
// 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($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 $revisions;
}
/**
......@@ -676,20 +698,23 @@ 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
* most current revision(s).
* @param array|bool $revision_ids
* The IDs of the revisions to load, or FALSE if this query is asking for
* the default revisions. Defaults to FALSE.
*
* @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) {
if (!is_array($revision_ids)) {
@trigger_error('Passing a single revision ID to "\Drupal\Core\Entity\Sql\SqlContentEntityStorage::buildQuery()" 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. See https://www.drupal.org/node/2924915.', E_USER_DEPRECATED);
}
$query->join($this->revisionTable, 'revision', "revision.{$this->idKey} = base.{$this->idKey} AND revision.{$this->revisionKey} IN (:revisionIds[])", [':revisionIds[]' => (array) $revision_ids]);
}
elseif ($this->revisionTable) {
$query->join($this->revisionTable, 'revision', "revision.{$this->revisionKey} = base.{$this->revisionKey}");
......@@ -1113,8 +1138,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 +1190,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 +1216,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;
}
}
}
......
......@@ -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]));
}
/**
......
......@@ -111,6 +111,8 @@ 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.',
'"\Drupal\Core\Entity\ContentEntityStorageBase::doLoadRevisionFieldItems()" is deprecated in Drupal 8.5.x and will be removed before Drupal 9.0.0. "\Drupal\Core\Entity\ContentEntityStorageBase::doLoadMultipleRevisionsFieldItems()" should be implemented instead. See https://www.drupal.org/node/2924915.',
'Passing a single revision ID to "\Drupal\Core\Entity\Sql\SqlContentEntityStorage::buildQuery()" 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. See https://www.drupal.org/node/2924915.',
];
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment