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;
}
}
<?php
namespace Drupal\KernelTests\Core\Entity;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\entity_test\Entity\EntityTestMulRev;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\user\Entity\User;
/**
* Test decoupled translation revisions.
*
* @group entity
*
* @coversDefaultClass \Drupal\Core\Entity\ContentEntityStorageBase
*/
class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = [
'system',
'entity_test',
'language',
];
/**
* The entity type bundle info service.
*
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
*/
protected $bundleInfo;
/**
* The entity storage.
*
* @var \Drupal\Core\Entity\ContentEntityStorageInterface
*/
protected $storage;
/**
* The translations of the test entity.
*
* @var \Drupal\Core\Entity\ContentEntityInterface[]
*/
protected $translations;
/**
* The previous revision identifiers for the various revision translations.
*
* @var int[]
*/
protected $previousRevisionId = [];
/**
* The previous unstranslatable field value.
*
* @var string[]
*/
protected $previousUntranslatableFieldValue;
/**
* The current edit sequence step index.
*
* @var int
*/
protected $stepIndex;
/**
* The current edit sequence step info.
*
* @var array
*/
protected $stepInfo;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$entity_type_id = 'entity_test_mulrev';
$this->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.
*