Commit 2909c978 authored by effulgentsia's avatar effulgentsia

Issue #2924724 by plach, hchonov, timmillwood, Gábor Hojtsy, amateescu,...

Issue #2924724 by plach, hchonov, timmillwood, Gábor Hojtsy, amateescu, gabesullice, catch: Add an API to create a new revision correctly handling multilingual pending revisions
parent 749f41ff
......@@ -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,51 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
$this->invokeHook('field_values_init', $entity);
}
/**
* Checks whether any entity revision is translated.
*
* 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()
*/
protected function isAnyRevisionTranslated(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'), 0)
->accessCheck(FALSE)
->range(0, 1);
if ($entity->getEntityType()->isRevisionable()) {
$query->allRevisions();
}
$result = $query->execute();
return !empty($result);
}
/**
* {@inheritdoc}
*/
......@@ -166,6 +212,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->isAnyRevisionTranslated($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}
*/
......
......@@ -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}
*/
......
......@@ -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.
*
......
......@@ -7,6 +7,26 @@
*/
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);
/**
* Returns the latest revision affecting the specified translation.
*
......
......@@ -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' => '<revision_id><value>' . $this->entity->getRevisionId() . '</value></revision_id>',
'default_langcode' => '<default_langcode><value>1</value></default_langcode>',
'revision_translation_affected' => '<revision_translation_affected><value>1</value></revision_translation_affected>',
'non_mul_field' => '<non_mul_field/>',
'non_rev_field' => '<non_rev_field/>',
'field_test_text' => '<field_test_text><value>' . $this->values['field_test_text']['value'] . '</value><format>' . $this->values['field_test_text']['format'] . '</format><processed><![CDATA[<p>' . $this->values['field_test_text']['value'] . '</p>]]></processed></field_test_text>',
];
......
......@@ -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().
*/
......
......@@ -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;
}
}
......@@ -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,95 @@ public function testSetNewRevision() {
}
}
/**
* Tests that revision translations are correctly detected.
*
* @covers \Drupal\Core\Entity\ContentEntityStorageBase::isAnyRevisionTranslated
*/
public function testIsAnyRevisionTranslated() {
/** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
$storage = $this->entityManager->getStorage('entity_test_mul');
$method = new \ReflectionMethod(get_class($storage), 'isAnyRevisionTranslated');
$method->setAccessible(TRUE);
// Check that a non-revisionable new entity is handled correctly.
$entity = EntityTestMul::create();
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $entity));
$entity->addTranslation('it');
$this->assertNotEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $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($method->invoke($storage, $entity));
// Check that removed translations are handled correctly.
$entity->save();
$entity->removeTranslation('it');
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertTrue($method->invoke($storage, $entity));
$entity->save();
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $entity));
$entity->addTranslation('de');
$entity->removeTranslation('de');
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $entity));
// Check that a non-revisionable not translated entity is handled correctly.
$entity = EntityTestMul::create();
$entity->save();
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $entity));
// Check that a non-revisionable translated entity is handled correctly.
$entity->addTranslation('it');
$entity->save();
$this->assertNotEmpty($entity->getTranslationLanguages(FALSE));
$this->assertTrue($method->invoke($storage, $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($method->invoke($storage, $entity));
$entity->addTranslation('it');
$this->assertNotEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $entity));
// Check that a revisionable not translated entity is handled correctly.
$entity = EntityTestMulRev::create();
$entity->save();
$this->assertEmpty($entity->getTranslationLanguages(FALSE));
$this->assertFalse($method->invoke($storage, $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($method->invoke($storage, $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($method->invoke($storage, $entity));
}
}
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