From 3deb640a9172aa57f07f34142857ac93b605ec77 Mon Sep 17 00:00:00 2001
From: webchick <drupal@webchick.net>
Date: Thu, 16 Apr 2015 09:39:18 -0700
Subject: [PATCH] Issue #2297817 by alexpott, pounard, Berdir, yched, Fabianx,
 plach, mkalkbrenner: Do not attempt field storage write when field content
 did not change

---
 .../Core/Entity/ContentEntityStorageBase.php  |  32 ++++
 core/lib/Drupal/Core/Entity/Entity.php        |   4 +-
 .../Entity/Sql/SqlContentEntityStorage.php    |  22 ++-
 core/lib/Drupal/Core/Field/FieldItemList.php  |  35 ++++
 .../Core/Field/FieldItemListInterface.php     |  13 ++
 .../Tests/Entity/EntityTranslationTest.php    |  13 ++
 .../Tests/Core/Field/FieldItemListTest.php    | 149 ++++++++++++++++++
 7 files changed, 258 insertions(+), 10 deletions(-)
 create mode 100644 core/tests/Drupal/Tests/Core/Field/FieldItemListTest.php

diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
index acc17bac2780..25d4a5a1c57a 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
@@ -206,4 +206,36 @@ protected function invokeFieldMethod($method, ContentEntityInterface $entity) {
     }
   }
 
+  /**
+   * Checks whether the field values changed compared to the original entity.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   Field definition of field to compare for changes.
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   Entity to check for field changes.
+   * @param \Drupal\Core\Entity\ContentEntityInterface $original
+   *   Original entity to compare against.
+   *
+   * @return bool
+   *   True if the field value changed from the original entity.
+   */
+  protected function hasFieldValueChanged(FieldDefinitionInterface $field_definition, ContentEntityInterface $entity, ContentEntityInterface $original) {
+    $field_name = $field_definition->getName();
+    $langcodes = array_keys($entity->getTranslationLanguages());
+    if ($langcodes !== array_keys($original->getTranslationLanguages())) {
+      // If the list of langcodes has changed, we need to save.
+      return TRUE;
+    }
+    foreach ($langcodes as $langcode) {
+      $items = $entity->getTranslation($langcode)->get($field_name)->filterEmptyItems();
+      $original_items = $original->getTranslation($langcode)->get($field_name)->filterEmptyItems();
+      // If the field items are not equal, we need to save.
+      if (!$items->equals($original_items)) {
+        return TRUE;
+      }
+    }
+
+    return FALSE;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Entity/Entity.php b/core/lib/Drupal/Core/Entity/Entity.php
index 764d639b2d43..cf0a66dbac62 100644
--- a/core/lib/Drupal/Core/Entity/Entity.php
+++ b/core/lib/Drupal/Core/Entity/Entity.php
@@ -156,8 +156,8 @@ public function label() {
    * {@inheritdoc}
    */
   public function urlInfo($rel = 'canonical', array $options = []) {
-    if ($this->isNew()) {
-      throw new EntityMalformedException(sprintf('The "%s" entity type has not been saved, and cannot have a URI.', $this->getEntityTypeId()));
+    if ($this->id() === NULL) {
+      throw new EntityMalformedException(sprintf('The "%s" entity cannot have a URI as it does have an ID', $this->getEntityTypeId()));
     }
 
     // The links array might contain URI templates set in annotations.
diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
index 22d2f92a281e..924f6a8eae92 100644
--- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
+++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
@@ -22,6 +22,7 @@
 use Drupal\Core\Entity\Query\QueryInterface;
 use Drupal\Core\Entity\Schema\DynamicallyFieldableEntityStorageSchemaInterface;
 use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\field\FieldStorageConfigInterface;
@@ -960,9 +961,6 @@ protected function doSave($id, EntityInterface $entity) {
       if ($this->revisionDataTable) {
         $this->saveToSharedTables($entity, $this->revisionDataTable);
       }
-      if ($this->revisionTable) {
-        $entity->setNewRevision(FALSE);
-      }
     }
     else {
       // Ensure the entity is still seen as new after assigning it an id,
@@ -990,11 +988,6 @@ protected function doSave($id, EntityInterface $entity) {
       if ($this->revisionDataTable) {
         $this->saveToSharedTables($entity, $this->revisionDataTable);
       }
-
-      $entity->enforceIsNew(FALSE);
-      if ($this->revisionTable) {
-        $entity->setNewRevision(FALSE);
-      }
     }
     $this->invokeFieldMethod($is_new ? 'insert' : 'update', $entity);
     $this->saveToDedicatedTables($entity, !$is_new);
@@ -1002,6 +995,10 @@ protected function doSave($id, EntityInterface $entity) {
     if (!$is_new && $this->dataTable) {
       $this->invokeTranslationHooks($entity);
     }
+    $entity->enforceIsNew(FALSE);
+    if ($this->revisionTable) {
+      $entity->setNewRevision(FALSE);
+    }
     return $return;
   }
 
@@ -1317,11 +1314,20 @@ protected function saveToDedicatedTables(ContentEntityInterface $entity, $update
       $vid = $id;
     }
 
+    $original = !empty($entity->original) ? $entity->original: NULL;
+
     foreach ($this->entityManager->getFieldDefinitions($entity_type, $bundle) as $field_name => $field_definition) {
       $storage_definition = $field_definition->getFieldStorageDefinition();
       if (!$table_mapping->requiresDedicatedTableStorage($storage_definition)) {
         continue;
       }
+
+      // When updating an existing revision, keep the existing records if the
+      // field values did not change.
+      if (!$entity->isNewRevision() && $original && !$this->hasFieldValueChanged($field_definition, $entity, $original)) {
+        continue;
+      }
+
       $table_name = $table_mapping->getDedicatedDataTableName($storage_definition);
       $revision_name = $table_mapping->getDedicatedRevisionTableName($storage_definition);
 
diff --git a/core/lib/Drupal/Core/Field/FieldItemList.php b/core/lib/Drupal/Core/Field/FieldItemList.php
index 930b4e17aeee..86bac79b75ba 100644
--- a/core/lib/Drupal/Core/Field/FieldItemList.php
+++ b/core/lib/Drupal/Core/Field/FieldItemList.php
@@ -98,6 +98,7 @@ public function filterEmptyItems() {
     $this->filter(function ($item) {
       return !$item->isEmpty();
     });
+    return $this;
   }
 
   /**
@@ -370,4 +371,38 @@ protected function defaultValueWidget(FormStateInterface $form_state) {
     return $form_state->get('default_value_widget');
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function equals(FieldItemListInterface $list_to_compare) {
+    $columns = $this->getFieldDefinition()->getFieldStorageDefinition()->getColumns();
+    $count1 = count($this);
+    $count2 = count($list_to_compare);
+    if ($count1 === 0 && $count2 === 0) {
+      // Both are empty we can safely assume that it did not change.
+      return TRUE;
+    }
+    if ($count1 !== $count2) {
+      // One of them is empty but not the other one so the value changed.
+      return FALSE;
+    }
+    $value1 = $this->getValue();
+    $value2 = $list_to_compare->getValue();
+    if ($value1 === $value2) {
+      return TRUE;
+    }
+    // If the values are not equal ensure a consistent order of field item
+    // properties and remove properties which will not be saved.
+    $callback = function (&$value) use ($columns) {
+      if (is_array($value)) {
+        $value = array_intersect_key($value, $columns);
+        ksort($value);
+      }
+    };
+    array_walk($value1, $callback);
+    array_walk($value2, $callback);
+
+    return $value1 === $value2;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Field/FieldItemListInterface.php b/core/lib/Drupal/Core/Field/FieldItemListInterface.php
index aeb600594bb3..e4ea12c6c9d3 100644
--- a/core/lib/Drupal/Core/Field/FieldItemListInterface.php
+++ b/core/lib/Drupal/Core/Field/FieldItemListInterface.php
@@ -94,6 +94,8 @@ public function defaultAccess($operation = 'view', AccountInterface $account = N
 
   /**
    * Filters out empty field items and re-numbers the item deltas.
+   *
+   * @return $this
    */
   public function filterEmptyItems();
 
@@ -258,4 +260,15 @@ public function defaultValuesFormSubmit(array $element, array &$form, FormStateI
    */
   public static function processDefaultValue($default_value, FieldableEntityInterface $entity, FieldDefinitionInterface $definition);
 
+  /**
+   * Determines equality to another object implementing FieldItemListInterface.
+   *
+   * @param \Drupal\Core\Field\FieldItemListInterface $list_to_compare
+   *   The field item list to compare to.
+   *
+   * @return bool
+   *   TRUE if the field item lists are equal, FALSE if not.
+   */
+  public function equals(FieldItemListInterface $list_to_compare);
+
 }
diff --git a/core/modules/system/src/Tests/Entity/EntityTranslationTest.php b/core/modules/system/src/Tests/Entity/EntityTranslationTest.php
index a3cf0a46a8cf..c8549e6b3054 100644
--- a/core/modules/system/src/Tests/Entity/EntityTranslationTest.php
+++ b/core/modules/system/src/Tests/Entity/EntityTranslationTest.php
@@ -460,6 +460,19 @@ protected function doTestEntityTranslationAPI($entity_type) {
     $hooks = $this->getHooksInfo();
     $this->assertFalse($hooks, 'No hooks are run when adding and removing a translation without storing it.');
 
+    // Check that hooks are fired only when actually storing data.
+    $entity = $this->reloadEntity($entity);
+    $entity->addTranslation($langcode2);
+    $entity->save();
+    $entity = $this->reloadEntity($entity);
+    $this->assertTrue($entity->hasTranslation($langcode2), 'Entity has translation after adding one and saving.');
+    $entity->removeTranslation($langcode2);
+    $entity->save();
+    $entity = $this->reloadEntity($entity);
+    $this->assertFalse($entity->hasTranslation($langcode2), 'Entity does not have translation after removing it and saving.');
+    // Reset hook firing information.
+    $this->getHooksInfo();
+
     // Verify that entity serialization does not cause stale references to be
     // left around.
     $entity = $this->reloadEntity($entity);
diff --git a/core/tests/Drupal/Tests/Core/Field/FieldItemListTest.php b/core/tests/Drupal/Tests/Core/Field/FieldItemListTest.php
new file mode 100644
index 000000000000..65e996e3545f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Field/FieldItemListTest.php
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file Contains \Drupal\Tests\Core\Field\FieldItemListTest.
+ */
+
+namespace Drupal\Tests\Core\Field;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\Field\FieldItemList;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Field\FieldItemList
+ * @group Field
+ */
+class FieldItemListTest extends UnitTestCase {
+
+  /**
+   * @covers ::equals
+   *
+   * @dataProvider providerTestEquals
+   */
+  public function testEquals($expected, FieldItemInterface $first_field_item = NULL, FieldItemInterface $second_field_item = NULL) {
+
+    // Mock the field type manager and place it in the container.
+    $field_type_manager = $this->getMock('Drupal\Core\Field\FieldTypePluginManagerInterface');
+    $container = new ContainerBuilder();
+    $container->set('plugin.manager.field.field_type', $field_type_manager);
+    \Drupal::setContainer($container);
+
+    $field_storage_definition = $this->getMock('Drupal\Core\Field\FieldStorageDefinitionInterface');
+    $field_storage_definition->expects($this->any())
+      ->method('getColumns')
+      ->willReturn([0 => '0', 1 => '1']);
+    $field_definition = $this->getMock('Drupal\Core\Field\FieldDefinitionInterface');
+    $field_definition->expects($this->any())
+      ->method('getFieldStorageDefinition')
+      ->willReturn($field_storage_definition);
+
+    $field_list_a = new FieldItemList($field_definition);
+    $field_list_b = new FieldItemList($field_definition);
+
+    // Set up the mocking necessary for creating field items.
+    $field_type_manager->expects($this->any())
+      ->method('createFieldItem')
+      ->willReturnOnConsecutiveCalls($first_field_item, $second_field_item);
+
+    // Set the field item values.
+    if ($first_field_item instanceof FieldItemInterface) {
+      $field_list_a->setValue($first_field_item);
+    }
+    if ($second_field_item instanceof FieldItemInterface) {
+      $field_list_b->setValue($second_field_item);
+    }
+
+    $this->assertEquals($expected, $field_list_a->equals($field_list_b));
+  }
+
+  /**
+   * Data provider for testEquals.
+   */
+  public function providerTestEquals() {
+    // Tests field item lists with no values.
+    $datasets[] = [TRUE];
+
+    /** @var \Drupal\Core\Field\FieldItemBase  $field_item_a */
+    $field_item_a = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $field_item_a->setValue([1]);
+    // Tests field item lists where one has a value and one does not.
+    $datasets[] = [FALSE, $field_item_a];
+
+    // Tests field item lists where both have the same value.
+    $datasets[] = [TRUE, $field_item_a, $field_item_a];
+
+    /** @var \Drupal\Core\Field\FieldItemBase  $fv */
+    $field_item_b = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $field_item_b->setValue([2]);
+    // Tests field item lists where both have the different values.
+    $datasets[] = [FALSE, $field_item_a, $field_item_b];
+
+    /** @var \Drupal\Core\Field\FieldItemBase  $fv */
+    $field_item_c = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $field_item_c->setValue(['0' => 1, '1' => 2]);
+    $field_item_d = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $field_item_d->setValue(['1' => 2, '0' => 1]);
+
+    // Tests field item lists where both have the differently ordered values.
+    $datasets[] = [TRUE, $field_item_c, $field_item_d];
+
+    return $datasets;
+  }
+
+  /**
+   * @covers ::equals
+   */
+  public function testEqualsEmptyItems() {
+    /** @var \Drupal\Core\Field\FieldItemBase  $fv */
+    $first_field_item = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $first_field_item->setValue(['0' => 1, '1' => 2]);
+    $second_field_item = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    $second_field_item->setValue(['1' => 2, '0' => 1]);
+    $empty_field_item = $this->getMockForAbstractClass('Drupal\Core\Field\FieldItemBase', [], '', FALSE);
+    // Mock the field type manager and place it in the container.
+    $field_type_manager = $this->getMock('Drupal\Core\Field\FieldTypePluginManagerInterface');
+    $container = new ContainerBuilder();
+    $container->set('plugin.manager.field.field_type', $field_type_manager);
+    \Drupal::setContainer($container);
+
+    $field_storage_definition = $this->getMock('Drupal\Core\Field\FieldStorageDefinitionInterface');
+    $field_storage_definition->expects($this->any())
+      ->method('getColumns')
+      ->willReturn([0 => '0', 1 => '1']);
+    $field_definition = $this->getMock('Drupal\Core\Field\FieldDefinitionInterface');
+    $field_definition->expects($this->any())
+      ->method('getFieldStorageDefinition')
+      ->willReturn($field_storage_definition);
+
+    $field_list_a = new FieldItemList($field_definition);
+    $field_list_b = new FieldItemList($field_definition);
+
+    // Set up the mocking necessary for creating field items.
+    $field_type_manager->expects($this->any())
+      ->method('createFieldItem')
+      ->willReturnOnConsecutiveCalls($first_field_item, $second_field_item, $empty_field_item, $empty_field_item);
+
+    // Set the field item values.
+    $field_list_a->setValue($first_field_item);
+    $field_list_b->setValue($second_field_item);
+    $field_list_a->appendItem($empty_field_item);
+
+    // Field list A has an empty item.
+    $this->assertEquals(FALSE, $field_list_a->equals($field_list_b));
+
+    // Field lists A and B have empty items.
+    $field_list_b->appendItem($empty_field_item);
+    $this->assertEquals(TRUE, $field_list_a->equals($field_list_b));
+
+    // Field list B has an empty item.
+    $field_list_a->filterEmptyItems();
+    $this->assertEquals(FALSE, $field_list_a->equals($field_list_b));
+
+    // Neither field lists A and B have empty items.
+    $field_list_b->filterEmptyItems();
+    $this->assertEquals(TRUE, $field_list_a->equals($field_list_b));
+  }
+
+}
-- 
GitLab