diff --git a/core/lib/Drupal/Core/Entity/EntityChangedInterface.php b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php index 747571799cd..2c92b0cf431 100644 --- a/core/lib/Drupal/Core/Entity/EntityChangedInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php @@ -37,6 +37,11 @@ interface EntityChangedInterface { /** * Gets the timestamp of the last entity change across all translations. * + * This method will return the highest timestamp across all translations. To + * check that no translation is older than in another version of the entity + * (e.g. to avoid overwriting newer translations with old data), compare each + * translation to the other version individually. + * * @return int * The timestamp of the last entity save operation across all * translations. diff --git a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php index b1fee2a2945..28d81ba6e4d 100644 --- a/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php +++ b/core/lib/Drupal/Core/Entity/Plugin/Validation/Constraint/EntityChangedConstraintValidator.php @@ -18,10 +18,23 @@ class EntityChangedConstraintValidator extends ConstraintValidator { /** @var \Drupal\Core\Entity\EntityInterface $entity */ if (!$entity->isNew()) { $saved_entity = \Drupal::entityManager()->getStorage($entity->getEntityTypeId())->loadUnchanged($entity->id()); - // A change to any other translation must add a violation to the current - // translation because there might be untranslatable shared fields. - if ($saved_entity && $saved_entity->getChangedTimeAcrossTranslations() > $entity->getChangedTimeAcrossTranslations()) { - $this->context->addViolation($constraint->message); + // Ensure that all the entity translations are the same as or newer + // than their current version in the storage in order to avoid + // reverting other changes. In fact the entity object that is being + // saved might contain an older entity translation when different + // translations are being concurrently edited. + if ($saved_entity) { + $common_translation_languages = array_intersect_key($entity->getTranslationLanguages(), $saved_entity->getTranslationLanguages()); + foreach (array_keys($common_translation_languages) as $langcode) { + // Merely comparing the latest changed timestamps across all + // translations is not sufficient since other translations may have + // been edited and saved in the meanwhile. Therefore, compare the + // changed timestamps of each entity translation individually. + if ($saved_entity->getTranslation($langcode)->getChangedTime() > $entity->getTranslation($langcode)->getChangedTime()) { + $this->context->addViolation($constraint->message); + break; + } + } } } } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php index c6461943ec3..2a6dfed6c2d 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php @@ -3,6 +3,7 @@ namespace Drupal\KernelTests\Core\Entity; use Drupal\Core\Entity\Plugin\Validation\Constraint\CompositeConstraintBase; +use Drupal\language\Entity\ConfigurableLanguage; /** * Tests the Entity Validation API. @@ -16,7 +17,7 @@ class EntityValidationTest extends EntityKernelTestBase { * * @var array */ - public static $modules = ['filter', 'text']; + public static $modules = ['filter', 'text', 'language']; /** * @var string @@ -39,6 +40,10 @@ class EntityValidationTest extends EntityKernelTestBase { protected function setUp() { parent::setUp(); + // Enable an additional language. + ConfigurableLanguage::createFromLangcode('de') + ->save(); + // Create the test field. module_load_install('entity_test'); entity_test_install(); @@ -200,4 +205,49 @@ class EntityValidationTest extends EntityKernelTestBase { $this->assertEqual($constraint->coversFields(), ['name', 'type'], 'Information about covered fields can be retrieved.'); } + /** + * Tests the EntityChangedConstraintValidator with multiple translations. + */ + public function testEntityChangedConstraintOnConcurrentMultilingualEditing() { + $this->installEntitySchema('entity_test_mulrev_changed'); + $storage = \Drupal::entityTypeManager() + ->getStorage('entity_test_mulrev_changed'); + + // Create a test entity. + $entity = $this->createTestEntity('entity_test_mulrev_changed'); + $entity->save(); + + $entity->setChangedTime($entity->getChangedTime() - 1); + $violations = $entity->validate(); + $this->assertEquals(1, $violations->count()); + $this->assertEqual($violations[0]->getMessage(), 'The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.'); + + $entity = $storage->loadUnchanged($entity->id()); + $translation = $entity->addTranslation('de'); + $entity->save(); + + // Ensure that the new translation has a newer changed timestamp than the + // default translation. + $this->assertGreaterThan($entity->getChangedTime(), $translation->getChangedTime()); + + // Simulate concurrent form editing by saving the entity with an altered + // non-translatable field in order for the changed timestamp to be updated + // across all entity translations. + $original_entity_time = $entity->getChangedTime(); + $entity->set('not_translatable', $this->randomString()); + $entity->save(); + // Simulate form submission of an uncached form by setting the previous + // timestamp of an entity translation on the saved entity object. This + // happens in the entity form API where we put the changed timestamp of + // the entity in a form hidden value and then set it on the entity which on + // form submit is loaded from the storage if the form is not yet cached. + $entity->setChangedTime($original_entity_time); + // Setting the changed timestamp from the user input on the entity loaded + // from the storage is used as a prevention from saving a form built with a + // previous version of the entity and thus reverting changes by other users. + $violations = $entity->validate(); + $this->assertEquals(1, $violations->count()); + $this->assertEqual($violations[0]->getMessage(), 'The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.'); + } + }