diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php index 7f36c11a67..1e6003cdc6 100644 --- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php @@ -6,6 +6,7 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\TypedData\TranslationStatusInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -148,6 +149,37 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values $this->invokeHook('field_values_init', $entity); } + /** + * {@inheritdoc} + */ + public function hasStoredTranslations(TranslatableInterface $entity) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + if ($entity->isNew()) { + return FALSE; + } + + if ($entity instanceof TranslationStatusInterface) { + foreach ($entity->getTranslationLanguages(FALSE) as $langcode => $language) { + if ($entity->getTranslationStatus($langcode) === TranslationStatusInterface::TRANSLATION_EXISTING) { + return TRUE; + } + } + } + + $query = $this->getQuery() + ->condition($this->entityType->getKey('id'), $entity->id()) + ->condition($this->entityType->getKey('default_langcode'), FALSE) + ->accessCheck(FALSE) + ->range(0, 1); + + if ($entity->getEntityType()->isRevisionable()) { + $query->allRevisions(); + } + + $result = $query->execute(); + return !empty($result); + } + /** * {@inheritdoc} */ @@ -166,6 +198,91 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr return $translation; } + /** + * {@inheritdoc} + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $new_revision = clone $entity; + + // For translatable entities, create a merged revision of the active + // translation and the other translations in the default revision. This + // permits the creation of pending revisions that can always be saved as the + // new default revision without reverting changes in other languages. + if (!$entity->isNew() && !$entity->isDefaultRevision() && $entity->isTranslatable() && $this->hasStoredTranslations($entity)) { + $active_langcode = $entity->language()->getId(); + $skipped_field_names = array_flip($this->getRevisionTranslationMergeSkippedFieldNames()); + + // Default to preserving the untranslatable field values in the default + // revision, otherwise we may expose data that was not meant to be + // accessible. + if (!isset($keep_untranslatable_fields)) { + // @todo Implement a more complete default logic in + // https://www.drupal.org/project/drupal/issues/2878556. + $keep_untranslatable_fields = FALSE; + } + + /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */ + $default_revision = $this->load($entity->id()); + foreach ($default_revision->getTranslationLanguages() as $langcode => $language) { + if ($langcode == $active_langcode) { + continue; + } + + $default_revision_translation = $default_revision->getTranslation($langcode); + $new_revision_translation = $new_revision->hasTranslation($langcode) ? + $new_revision->getTranslation($langcode) : $new_revision->addTranslation($langcode); + + /** @var \Drupal\Core\Field\FieldItemListInterface[] $sync_items */ + $sync_items = array_diff_key( + $keep_untranslatable_fields ? $default_revision_translation->getTranslatableFields() : $default_revision_translation->getFields(), + $skipped_field_names + ); + foreach ($sync_items as $field_name => $items) { + $new_revision_translation->set($field_name, $items->getValue()); + } + + // Make sure the "revision_translation_affected" flag is recalculated. + $new_revision_translation->setRevisionTranslationAffected(NULL); + + // No need to copy untranslatable field values more than once. + $keep_untranslatable_fields = TRUE; + } + } + + // Eventually mark the new revision as such. + $new_revision->setNewRevision(); + $new_revision->isDefaultRevision($default); + + // Actually make sure the current translation is marked as affected, even if + // there are no explicit changes, to be sure this revision can be related + // to the correct translation. + $new_revision->setRevisionTranslationAffected(TRUE); + + return $new_revision; + } + + /** + * Returns an array of field names to skip when merging revision translations. + * + * @return array + * An array of field names. + */ + protected function getRevisionTranslationMergeSkippedFieldNames() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->getEntityType(); + + // A list of known revision metadata fields which should be skipped from + // the comparision. + $field_names = [ + $entity_type->getKey('revision'), + $entity_type->getKey('revision_translation_affected'), + ]; + $field_names = array_merge($field_names, array_values($entity_type->getRevisionMetadataKeys())); + + return $field_names; + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php index b0725251e5..6aa18ff840 100644 --- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php @@ -4,6 +4,8 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityStorageInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\TranslatableInterface; /** * Provides a key value backend for content entities. @@ -18,6 +20,20 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr // https://www.drupal.org/node/2618436. } + /** + * {@inheritdoc} + */ + public function hasStoredTranslations(TranslatableInterface $entity) { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL) { + return NULL; + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php index f92d419aa5..d23808b1b6 100644 --- a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php @@ -7,6 +7,20 @@ */ interface RevisionableStorageInterface { + /** + * Creates a new revision starting off from the specified entity object. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity + * The revisionable entity object being modified. + * @param bool $default + * (optional) Whether the new revision should be marked as default. Defaults + * to TRUE. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface + * A new entity revision object. + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE); + /** * Loads a specific entity revision. * diff --git a/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php index 1a6b73784e..a09613ec16 100644 --- a/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php @@ -7,6 +7,45 @@ */ interface TranslatableRevisionableStorageInterface extends TranslatableStorageInterface, RevisionableStorageInterface { + /** + * Creates a new revision starting off from the specified entity object. + * + * When dealing with a translatable entity, this will merge the default + * revision with the active translation of the passed entity. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity + * The revisionable entity object being modified. + * @param bool $default + * (optional) Whether the new revision should be marked as default. Defaults + * to TRUE. + * @param bool|null $keep_untranslatable_fields + * (optional) Whether untranslatable field values should be kept or copied + * from the default revision when generating a merged revision. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\RevisionableInterface + * A new translatable entity revision object. + */ + public function createRevision(RevisionableInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL); + + /** + * Checks whether the specified entity has stored translations. + * + * A revisionable entity can have translations in a pending revision, hence + * the default revision may appear as not translated. This determines whether + * the entity has any translation in the storage and thus should be considered + * as multilingual. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\TranslatableInterface $entity + * The entity object to be checked. + * + * @return bool + * TRUE if the entity has at least one translation in any revision, FALSE + * otherwise. + * + * @see \Drupal\Core\TypedData\TranslatableInterface::getTranslationLanguages() + */ + public function hasStoredTranslations(TranslatableInterface $entity); + /** * Returns the latest revision affecting the specified translation. * diff --git a/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php index c9d93fef4f..aa6ba6befa 100644 --- a/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php @@ -27,4 +27,21 @@ */ public function createTranslation(ContentEntityInterface $entity, $langcode, array $values = []); + /** + * Checks whether the specified entity has stored translations. + * + * This determines whether the entity has any translation in the storage and + * thus should be considered as multilingual. + * + * @param \Drupal\Core\Entity\EntityInterface|\Drupal\Core\Entity\TranslatableInterface $entity + * The entity object to be checked. + * + * @return bool + * TRUE if the entity has at least one translation in the storage, FALSE + * otherwise. + * + * @see \Drupal\Core\TypedData\TranslatableInterface::getTranslationLanguages() + */ + public function hasStoredTranslations(TranslatableInterface $entity); + } diff --git a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php index 5df9d75db0..72da3cb4eb 100644 --- a/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php +++ b/core/modules/serialization/tests/src/Kernel/EntitySerializationTest.php @@ -154,6 +154,7 @@ public function testNormalize() { ['value' => TRUE], ], 'non_rev_field' => [], + 'non_mul_field' => [], 'field_test_text' => [ [ 'value' => $this->values['field_test_text']['value'], @@ -226,6 +227,7 @@ public function testSerialize() { 'revision_id' => '' . $this->entity->getRevisionId() . '', 'default_langcode' => '1', 'revision_translation_affected' => '1', + 'non_mul_field' => '', 'non_rev_field' => '', 'field_test_text' => '' . $this->values['field_test_text']['value'] . '' . $this->values['field_test_text']['format'] . '' . $this->values['field_test_text']['value'] . '

]]>
', ]; diff --git a/core/modules/system/tests/modules/entity_test/entity_test.module b/core/modules/system/tests/modules/entity_test/entity_test.module index 025cb5fc2f..7d4df70bae 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.module +++ b/core/modules/system/tests/modules/entity_test/entity_test.module @@ -206,6 +206,23 @@ function entity_test_entity_bundle_info() { return $bundles; } +/** + * Implements hook_entity_bundle_info_alter(). + */ +function entity_test_entity_bundle_info_alter(&$bundles) { + $entity_info = \Drupal::entityTypeManager()->getDefinitions(); + $state = \Drupal::state(); + foreach ($bundles as $entity_type_id => &$all_bundle_info) { + if ($entity_info[$entity_type_id]->getProvider() == 'entity_test') { + if ($state->get('entity_test.translation')) { + foreach ($all_bundle_info as $bundle_name => &$bundle_info) { + $bundle_info['translatable'] = TRUE; + } + } + } + } +} + /** * Implements hook_entity_view_mode_info_alter(). */ diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php index 2bfdf41419..724385fd94 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php @@ -3,6 +3,7 @@ namespace Drupal\entity_test\Entity; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; /** * Defines the test entity class. @@ -53,7 +54,13 @@ class EntityTestMulRev extends EntityTestRev { * {@inheritdoc} */ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { - return parent::baseFieldDefinitions($entity_type) + \Drupal::state()->get($entity_type->id() . '.additional_base_field_definitions', []); + $fields = parent::baseFieldDefinitions($entity_type) + \Drupal::state()->get($entity_type->id() . '.additional_base_field_definitions', []); + + $fields['non_mul_field'] = BaseFieldDefinition::create('string') + ->setLabel(t('Non translatable')) + ->setDescription(t('A non-translatable string field')); + + return $fields; } } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php new file mode 100644 index 0000000000..f2dccbdd58 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php @@ -0,0 +1,465 @@ +installEntitySchema($entity_type_id); + $this->storage = $this->container->get('entity_type.manager') + ->getStorage($entity_type_id); + + $this->installConfig(['language']); + $langcodes = ['it', 'fr']; + foreach ($langcodes as $langcode) { + ConfigurableLanguage::createFromLangcode($langcode)->save(); + } + + $values = [ + 'name' => $this->randomString(), + 'status' => 1, + ]; + User::create($values)->save(); + + // Make sure entity bundles are translatable. + $this->state->set('entity_test.translation', TRUE); + $this->bundleInfo = \Drupal::service('entity_type.bundle.info'); + $this->bundleInfo->clearCachedBundles(); + } + + /** + * Data provider for ::testDecoupledDefaultRevisions. + */ + public function dataTestDecoupledPendingRevisions() { + $sets = []; + + $sets['Intermixed languages - No initial default translation'] = [[ + ['en', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ]]; + + $sets['Intermixed languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ]]; + + $sets['Alternate languages - No initial default translation'] = [[ + ['en', TRUE], + ['en', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', FALSE], + ['en', TRUE], + ['it', FALSE], + ['it', FALSE], + ['it', TRUE], + ]]; + + $sets['Alternate languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['en', TRUE], + ['en', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', TRUE], + ['it', FALSE], + ['it', FALSE], + ['it', TRUE], + ]]; + + $sets['Multiple languages - No initial default translation'] = [[ + ['en', TRUE], + ['it', FALSE], + ['fr', FALSE], + ['en', FALSE], + ['en', TRUE], + ['it', TRUE], + ['fr', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['fr', TRUE], + ['it', TRUE], + ['fr', TRUE], + ]]; + + $sets['Multiple languages - With initial default translation'] = [[ + ['en', TRUE], + ['it', TRUE], + ['fr', TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ['fr', FALSE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['fr', TRUE], + ['it', TRUE], + ['fr', TRUE], + ]]; + + return $sets; + } + + /** + * Test decoupled default revisions. + * + * @param array[] $sequence + * An array with arrays of arguments for the ::doSaveNewRevision() method as + * values. Every child array corresponds to a method invocation. + * + * @covers ::createRevision + * + * @dataProvider dataTestDecoupledPendingRevisions + */ + public function testDecoupledPendingRevisions($sequence) { + $revision_id = $this->doTestEditSequence($sequence); + $this->assertEquals(count($sequence), $revision_id); + } + + /** + * Data provider for ::testUntranslatableFields. + */ + public function dataTestUntranslatableFields() { + $sets = []; + + $sets['Default behavior - Untranslatable fields affect all revisions'] = [ + [ + ['en', TRUE, TRUE], + ['it', TRUE, TRUE], + ['en', FALSE], + ['it', FALSE], + ['en', TRUE], + ['it', TRUE], + ], + ]; + + return $sets; + } + + /** + * Tests that untranslatable fields are handled correctly. + * + * @param array[] $sequence + * An array with arrays of arguments for the ::doSaveNewRevision() method as + * values. Every child array corresponds to a method invocation. + * + * @covers ::createRevision + * + * @dataProvider dataTestUntranslatableFields + */ + public function testUntranslatableFields($sequence) { + // Test that a new entity is always valid. + $entity = EntityTestMulRev::create(); + $entity->set('non_mul_field', 0); + $violations = $entity->validate(); + $this->assertEmpty($violations); + + // Test the specified sequence. + $this->doTestEditSequence($sequence); + } + + /** + * Actually tests an edit step sequence. + * + * @param array[] $sequence + * An array of sequence steps. + * + * @return int + * The latest saved revision id. + */ + protected function doTestEditSequence($sequence) { + $revision_id = NULL; + foreach ($sequence as $index => $step) { + $this->stepIndex = $index; + $revision_id = call_user_func_array([$this, 'doEditStep'], $step); + } + return $revision_id; + } + + /** + * Saves a new revision of the test entity. + * + * @param string $active_langcode + * The language of the translation for which a new revision will be saved. + * @param bool $default_revision + * Whether the revision should be flagged as the default revision. + * @param bool $untranslatable_update + * (optional) Whether an untranslatable field update should be performed. + * Defaults to FALSE. + * @param bool $valid + * (optional) Whether entity validation is expected to succeed. Defaults to + * TRUE. + * + * @return int + * The new revision identifier. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function doEditStep($active_langcode, $default_revision, $untranslatable_update = FALSE, $valid = TRUE) { + $this->stepInfo = [$active_langcode, $default_revision, $untranslatable_update, $valid]; + + // Initialize previous data tracking. + if (!isset($this->translations)) { + $this->translations[$active_langcode] = EntityTestMulRev::create(); + $this->previousRevisionId[$active_langcode] = 0; + } + if (!isset($this->translations[$active_langcode])) { + $this->translations[$active_langcode] = reset($this->translations)->addTranslation($active_langcode); + $this->previousRevisionId[$active_langcode] = 0; + } + + // We want to update previous data only if we expect a valid result, + // otherwise we would be just polluting it with invalid values. + if ($valid) { + $entity = &$this->translations[$active_langcode]; + $previous_revision_id = &$this->previousRevisionId[$active_langcode]; + } + else { + $entity = clone $this->translations[$active_langcode]; + $previous_revision_id = $this->previousRevisionId[$active_langcode]; + } + + // Check that after instantiating a new revision for the specified + // translation, we are resuming work from where we left the last time. If + // that is the case, the label generated for the previous revision should + // match the stored one. + if (!$entity->isNew()) { + $previous_label = NULL; + if (!$entity->isNewTranslation()) { + $previous_label = $this->generateNewEntityLabel($entity, $previous_revision_id); + } + $previous_revision_id = (int) $entity->getLoadedRevisionId(); + $latest_affected_revision_id = $this->storage->getLatestTranslationAffectedRevisionId($entity->id(), $entity->language()->getId()); + /** @var \Drupal\Core\Entity\ContentEntityInterface $latest_affected_revision */ + $latest_affected_revision = isset($latest_affected_revision_id) ? + $this->storage->loadRevision($latest_affected_revision_id) : $this->storage->load($entity->id()); + $translation = $latest_affected_revision->hasTranslation($active_langcode) ? + $latest_affected_revision->getTranslation($active_langcode) : $latest_affected_revision->addTranslation($active_langcode); + $entity = $this->storage->createRevision($translation, $default_revision); + $this->assertEquals($default_revision, $entity->isDefaultRevision()); + $this->assertEquals($translation->getLoadedRevisionId(), $entity->getLoadedRevisionId()); + $this->assertEquals($previous_label, $entity->label(), $this->formatMessage('Loaded translatable field value does not match the previous one.')); + } + + $value = $entity->get('non_mul_field')->value; + if (isset($previous_untranslatable_field_value)) { + $this->assertEquals($previous_untranslatable_field_value, $value, $this->formatMessage('Loaded untranslatable field value does not match the previous one.')); + } + + // Perform a change and store it. + $label = $this->generateNewEntityLabel($entity, $previous_revision_id, TRUE); + $entity->set('name', $label); + if ($untranslatable_update) { + // Store the revision ID of the previous untranslatable fields update in + // the new value, besides the upcoming revision ID. Useful to analyze test + // failures. + $prev = 0; + if (isset($value)) { + preg_match('/^\d+ -> (\d+)$/', $value, $matches); + $prev = $matches[1]; + } + $value = $prev . ' -> ' . ($entity->getLoadedRevisionId() + 1); + $entity->set('non_mul_field', $value); + } + + $violations = $entity->validate(); + $messages = []; + foreach ($violations as $violation) { + /** \Symfony\Component\Validator\ConstraintViolationInterface */ + $messages[] = $violation->getMessage(); + } + $this->assertEquals($valid, !$violations->count(), $this->formatMessage('Validation does not match the expected result: %s', implode(', ', $messages))); + + if ($valid) { + $entity->save(); + + // Reload the current revision translation and the default revision to + // make sure data was stored correctly. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->storage->loadRevision($entity->getRevisionId()); + $entity = $entity->getTranslation($active_langcode); + /** @var \Drupal\Core\Entity\ContentEntityInterface $default_entity */ + $default_entity = $this->storage->loadUnchanged($entity->id()); + + // Verify that the values for the current revision translation match the + // expected ones, while for the other translations they match the default + // revision. We also need to verify that only the current revision + // translation was marked as affected. + foreach ($entity->getTranslationLanguages() as $langcode => $language) { + $translation = $entity->getTranslation($langcode); + $rta_expected = $langcode == $active_langcode || $untranslatable_update; + $this->assertEquals($rta_expected, $translation->isRevisionTranslationAffected(), $this->formatMessage("'$langcode' translation incorrectly affected")); + $label_expected = $label; + if ($langcode !== $active_langcode) { + $default_translation = $default_entity->hasTranslation($langcode) ? $default_entity->getTranslation($langcode) : $default_entity; + $label_expected = $default_translation->label(); + } + $this->assertEquals($label_expected, $translation->label(), $this->formatMessage("Incorrect '$langcode' translation label")); + } + } + + return $entity->getRevisionId(); + } + + /** + * Generates a new label for the specified revision. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $revision + * An entity object. + * @param int $previous_revision_id + * The previous revision identifier for this revision translation. + * @param bool $next + * (optional) Whether the label describes the current revision or the one + * to be created. Defaults to FALSE. + * + * @return string + * A revision label. + */ + protected function generateNewEntityLabel(ContentEntityInterface $revision, $previous_revision_id, $next = FALSE) { + $language_label = $revision->language()->getName(); + $revision_type = $revision->isDefaultRevision() ? 'Default' : 'Pending'; + $revision_id = $next ? $this->storage->getLatestRevisionId($revision->id()) + 1 : $revision->getLoadedRevisionId(); + return sprintf('%s (%s %d -> %d)', $language_label, $revision_type, $previous_revision_id, $revision_id); + } + + /** + * Formats an assertion message. + * + * @param string $message + * The human-readable message. + * + * @return string + * The formatted message. + */ + protected function formatMessage($message) { + $args = func_get_args(); + array_shift($args); + $params = array_merge($args, $this->stepInfo); + array_unshift($params, $this->stepIndex + 1); + array_unshift($params, '[Step %d] ' . $message . ' (langcode: %s, default_revision: %d, untranslatable_update: %d, valid: %d)'); + return call_user_func_array('sprintf', $params); + } + + /** + * Tests that internal properties are preserved while creating a new revision. + */ + public function testInternalProperties() { + $entity = EntityTestMulRev::create(); + $this->doTestInternalProperties($entity); + + $entity = EntityTestMulRev::create(); + $entity->save(); + $this->doTestInternalProperties($entity); + + /** @var \Drupal\entity_test\Entity\EntityTestMulRev $translation */ + $translation = EntityTestMulRev::create()->addTranslation('it'); + $translation->save(); + $this->doTestInternalProperties($translation); + } + + /** + * Checks that internal properties are preserved for the specified entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * An entity object. + */ + protected function doTestInternalProperties(ContentEntityInterface $entity) { + $this->assertFalse($entity->isValidationRequired()); + $entity->setValidationRequired(TRUE); + $this->assertTrue($entity->isValidationRequired()); + $new_revision = $this->storage->createRevision($entity); + $this->assertTrue($new_revision->isValidationRequired()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php index e82d77e713..ba2a29a2a5 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityRevisionTranslationTest.php @@ -2,6 +2,7 @@ namespace Drupal\KernelTests\Core\Entity; +use Drupal\entity_test\Entity\EntityTestMul; use Drupal\entity_test\Entity\EntityTestMulRev; use Drupal\language\Entity\ConfigurableLanguage; @@ -23,9 +24,11 @@ class EntityRevisionTranslationTest extends EntityKernelTestBase { protected function setUp() { parent::setUp(); - // Enable an additional language. + // Enable some additional languages. ConfigurableLanguage::createFromLangcode('de')->save(); + ConfigurableLanguage::createFromLangcode('it')->save(); + $this->installEntitySchema('entity_test_mul'); $this->installEntitySchema('entity_test_mulrev'); } @@ -186,4 +189,93 @@ public function testSetNewRevision() { } } + /** + * Tests that revision translations are correctly detected. + * + * @covers \Drupal\Core\Entity\ContentEntityStorageBase::hasStoredTranslations + */ + public function testHasStoredTranslations() { + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityManager->getStorage('entity_test_mul'); + + // Check that a non-revisionable new entity is handled correctly. + $entity = EntityTestMul::create(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + $entity->addTranslation('it'); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that not yet stored translations are handled correctly. + $entity = EntityTestMul::create(); + $entity->save(); + $entity->addTranslation('it'); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that removed translations are handled correctly. + $entity->save(); + $entity->removeTranslation('it'); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + $entity->save(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + $entity->addTranslation('de'); + $entity->removeTranslation('de'); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a non-revisionable not translated entity is handled correctly. + $entity = EntityTestMul::create(); + $entity->save(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a non-revisionable translated entity is handled correctly. + $entity->addTranslation('it'); + $entity->save(); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + + /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */ + $storage = $this->entityManager->getStorage('entity_test_mulrev'); + + // Check that a revisionable new entity is handled correctly. + $entity = EntityTestMulRev::create(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + $entity->addTranslation('it'); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a revisionable not translated entity is handled correctly. + $entity = EntityTestMulRev::create(); + $entity->save(); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertFalse($storage->hasStoredTranslations($entity)); + + // Check that a revisionable translated pending revision is handled + // correctly. + /** @var \Drupal\Core\Entity\ContentEntityInterface $new_revision */ + $new_revision = $storage->createRevision($entity, FALSE); + $new_revision->addTranslation('it'); + $new_revision->save(); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->loadUnchanged($entity->id()); + $this->assertEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertNotEmpty($new_revision->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + + // Check that a revisionable translated default revision is handled + // correctly. + $new_revision->isDefaultRevision(TRUE); + $new_revision->save(); + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $storage->loadUnchanged($entity->id()); + $this->assertNotEmpty($entity->getTranslationLanguages(FALSE)); + $this->assertNotEmpty($new_revision->getTranslationLanguages(FALSE)); + $this->assertTrue($storage->hasStoredTranslations($entity)); + } + }