This was split off of #2860097: Ensure that content translations can be moderated independently.

Problem/Motivation

After many discussions in #2860097: Ensure that content translations can be moderated independently and #2766957: Forward revisions + translation UI can result in forked draft revisions, we reached consensus that a pending revision is safe to use in a multilingual context only when it affects a single language. However creating a new revision that correctly takes this logic into account is not a trivial task.

Proposed resolution

Add a ContentEntityInterface::createNewRevision() method returning a new entity object that is a merge of the active entity translation and the translations available in the default revision. Such an object can be saved and made the new default revision without affecting pending revisions in other languages.

Remaining tasks

User interface changes

None

API changes

Added the RevisionableStorageInterface::createRevision() method.

Data model changes

None

CommentFileSizeAuthor
#70 interdiff.txt2.3 KBeffulgentsia
#64 entity-new_revision-2924724-64.interdiff.txt682 bytesplach
#64 entity-new_revision-2924724-64.patch34.14 KBplach
#62 entity-new_revision-2924724-62.interdiff.txt7.42 KBplach
#62 entity-new_revision-2924724-62.patch34.14 KBplach
#55 entity-new_revision-2924724-55.interdiff.txt692 bytesplach
#55 entity-new_revision-2924724-55.patch35.89 KBplach
#54 entity-new_revision-2924724-54.interdiff.txt703 bytesplach
#54 entity-new_revision-2924724-54.patch35.9 KBplach
#52 entity-new_revision-2924724-52.interdiff.txt12.08 KBplach
#52 entity-new_revision-2924724-52.patch35.9 KBplach
#47 entity-new_revision-2924724-47.interdiff.txt4.02 KBplach
#47 entity-new_revision-2924724-47.patch35.27 KBplach
#44 entity-new_revision-2924724-44.interdiff.txt9.06 KBplach
#44 entity-new_revision-2924724-44.patch35.29 KBplach
#37 entity-new_revision-2924724-37.interdiff.txt756 bytesplach
#37 entity-new_revision-2924724-37.patch33.74 KBplach
#35 entity-new_revision-2924724-35.interdiff.txt9.49 KBplach
#35 entity-new_revision-2924724-35.patch33.74 KBplach
#31 entity-new_revision-2924724-30.interdiff.txt824 bytesplach
#31 entity-new_revision-2924724-30.review.patch31.62 KBplach
#31 entity-new_revision-2924724-30.patch46.91 KBplach
#29 entity-new_revision-2924724-28.interdiff.txt686 bytesplach
#28 entity-new_revision-2924724-28.interdiff.txt686 bytesplach
#28 entity-new_revision-2924724-28.review.patch31.64 KBplach
#28 entity-new_revision-2924724-28.patch46.94 KBplach
#26 entity-new_revision-2924724-26.interdiff.txt5.07 KBplach
#26 entity-new_revision-2924724-26.review.patch31.61 KBplach
#26 entity-new_revision-2924724-26.patch46.91 KBplach
#24 entity-new_revision-2924724-24.patch45.84 KBplach
#24 entity-new_revision-2924724-18.review.patch30.39 KBplach
#24 entity-new_revision-2924724-24.interdiff.txt10.03 KBplach
#2 entity-new_revision-2924724-2.patch21.41 KBplach
#4 entity-new_revision-2924724-4.patch21.57 KBplach
#4 entity-new_revision-2924724-4.interdiff.txt9.2 KBplach
#8 entity-new_revision-2924724-7.interdiff.txt1.4 KBplach
#8 entity-new_revision-2924724-7.patch22.13 KBplach
#11 entity-new_revision-2924724-11.patch44.83 KBplach
#11 entity-new_revision-2924724-11.interdiff.txt25.39 KBplach
#13 entity-new_revision-2924724-12.patch32.98 KBplach
#13 entity-new_revision-2924724-13.patch32.98 KBplach
#14 entity-new_revision-2924724-14.patch46.37 KBplach
#14 entity-new_revision-2924724-14.review.patch30.08 KBplach
#17 entity-new_revision-2924724-17.interdiff.txt3.64 KBplach
#17 entity-new_revision-2924724-17.review.patch30.29 KBplach
#17 entity-new_revision-2924724-17.patch45.59 KBplach
#18 entity-new_revision-2924724-18.interdiff.txt5.38 KBplach
#18 entity-new_revision-2924724-18.review.patch30.39 KBplach
#18 entity-new_revision-2924724-18.patch45.69 KBplach
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

plach created an issue. See original summary.

plach’s picture

Status: Active » Needs review
FileSize
21.41 KB

This should be more or less complete. The only outstanding issue is deciding how to deal with the Queries class.

plach’s picture

Spoke with @catch about this, we both think it should be fine to just move the Queries methods onto the storage class.

plach’s picture

Status: Needs review » Needs work

The last submitted patch, 4: entity-new_revision-2924724-4.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

Gábor Hojtsy’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -377,6 +377,70 @@ public function getRevisionId() {
    +    $revision_type = $this->get('revision_type')->value;
    +    return isset($revision_type) && intval($revision_type) === 1;
    

    Hm is this really a magic constant? (No actual constant for revision type values?)

  2. +++ b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php
    @@ -106,4 +106,22 @@ public function getLoadedRevisionId();
    +   * When dealing with a translatable entity, this will merge the default
    +   * revision with the latest affected revision for the entity active language.
    +   *
    +   * @param bool $default_revision
    +   *   (optional) Whether the new revision should be marked as default. Defaults
    +   *   to TRUE.
    +   * @param null $copy_untranslatable_fields
    +   *   (optional) Whether untranslatable field values should be copied over when
    +   *   generating a merged revision. Defaults to the behavior specified by the
    +   *   bundle settings.
    +   *
    +   * @return static
    +   */
    +  public function createNewRevision($default_revision = TRUE, $copy_untranslatable_fields = NULL);
    

    Is the method name correct if the method always starts creating the revision from the default revision? I see naming it createNewDefaultRevision() would not be correct because the new revision to create is not necessarily default, but the source data used is from the default revision (not necessarily the latest revision). That may be important to reflect in the method name somehow though.

  3. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
    @@ -5,7 +5,7 @@
    -interface ContentEntityStorageInterface extends EntityStorageInterface {
    +interface ContentEntityStorageInterface extends RevisionableEntityStorageInterface {
    
    @@ -40,4 +39,17 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +  public function getLatestAffectedRevisionId($id, $langcode);
    

    These are all BC changes. I see you added default implementations to the base class, which mitigates this a bit but it would fatal projects using this interface and not using the base class. We are not supposed to make changes like that.

    I see if we introduce a new interface then we cannot mandate that is implemented by content entity storages in general. Can we still work with that in the implementation, maybe throw an exception if we don't have the right data due to missing methods? That would at least only make the new addition broken for people with old interfaces. That is backwards compatible.

    (Better ideas welcome, just thinking out loud).

amateescu’s picture

I've split the addition of a getLatestRevisionId() method to a new issue (#2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision) because I think it should be easier to discuss it in a smaller scope.

plach’s picture

Status: Needs work » Needs review
FileSize
1.4 KB
22.13 KB

This should fix test failures.

@Gábor Hojtsy:

1: That's an unrelated hunk that was erroneously made part of the patch, it actually belongs in #2891215: Add a way to track whether a revision was default when originally created (to be heavily discussed ;) so I removed it, thanks!
2: Well, the default revision is the base for all translations except the active one, which is what you are supposed to be changing/saving. All values of the active translation are retained, even if you already performed some unsaved changes, so I think the method name should be correct. This method does not guarantee you do the right thing, i.e. changing only the active translation, but at least it guides you toward doing it.
3: According to our (not yet official) policy, those should be allowed changes:

Interfaces within non-experimental, non-test modules not tagged with either @api or @internal
[...] we reserve the ability to add methods to these interfaces in minor releases to support new features. [...] Where the interface is implemented directly for other reasons, be prepared to add support for new methods in minor releases

Am I missing something?

plach’s picture

Status: Needs review » Needs work

Spoke with @hchonov about this, we have a few things to fix:

  • We need to move this logic to the storage, as that's the place where we instantiate entity (revision) objects.
  • We need to reverse the current cloning logic: we should clone the current object and then copy field values from the default revision over, otherwise the dynamic properties on the object would be lost.
  • We need to make sure that the revisions merge dance is skipped when the entity is translatable but it has no translations. This is trickier than it seems because the entity may have translations in pending revisions, so we likely need an entity query to determine that.

Additionally Hristo is not happy with the current method name, but we agreed to defer the bikeshedding to when everything else is fixed ;)

plach’s picture

Priority: Major » Critical

This is blocking a critical task.

plach’s picture

Status: Needs work » Needs review
FileSize
44.83 KB
25.39 KB

This should address #9:

85ce3642b9 DEV 2924724: Synced changes from #2926540.
2ddb7cba63 DEV 2924724: Moved ::createNewRevision to the entity storage.
5298687f67 DEV 2924724: Removed unused variable.
f340ec6266 DEV 2924724: Switched to "entity_test_mulrev" to make tests faster.
b304ec34e4 DEV 2924724: Ensured internal properties are preserved while creating a new revision.
74c1bd27ab DEV 2924724: Skipped merge for non translated entities.

Status: Needs review » Needs work

The last submitted patch, 11: entity-new_revision-2924724-11.patch, failed testing. View results

plach’s picture

Status: Needs work » Needs review
FileSize
32.98 KB
32.98 KB

The previous patch got a bit messed up, the interdiff was correct though.

plach’s picture

Title: Add an API to create a new revision correctly handling multilingual pending revisions » [PP-1] Add an API to create a new revision correctly handling multilingual pending revisions
FileSize
46.37 KB
30.08 KB

This now depends on #2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision. Attaching a combined patch and patch to review. Leaving in needs review status to avoid blocking progress here.

The last submitted patch, 14: entity-new_revision-2924724-14.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

plach’s picture

plach’s picture

plach’s picture

Fixed another problem that was not caught by the current test coverage.

plach’s picture

Issue summary: View changes
plach’s picture

Issue summary: View changes
plach’s picture

Issue summary: View changes
Gábor Hojtsy’s picture

@plach re 7-8/3 you are right, these changes can be made according to our policy at least. :)

hchonov’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
    @@ -7,6 +7,25 @@
    +   * Checks whether the specified has stored translations.
    ...
    +   * A revisionable entity can have translations in a pending revision, hence
    +   * the default revision may appear as not translated. This method allows to
    +   * determine whether the entity has any translation in the storage and thus
    +   * should be considered as multilingual.
    ...
    +  public function hasTranslations(ContentEntityInterface $entity);
    

    If the name of the method is hasTranslations then we should check only if the current entity object has more than one entity translation and place it on the entity class.

    If we want to check if the entity has translations in any pending revisions then such a method should be placed in the storage and the name should be something like hasTranslationsInPendingRevisions().

  2. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -148,6 +148,29 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
    +    if ($entity->isNew()) {
    +      $result = FALSE;
    ...
    +      if ($entity->getEntityType()->isRevisionable()) {
    +        $query->allRevisions();
    

    The entity might have translations even if it is new :). I mean this in case we place the method on the entity class and use it to check only the current state.

  3. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -148,6 +148,29 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
    +      $result = FALSE;
    ...
    +      if ($entity->getEntityType()->isRevisionable()) {
    +        $query->allRevisions();
    +      }
    
    +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
    @@ -7,6 +7,25 @@
    +  public function hasTranslations(ContentEntityInterface $entity);
    

    If we move the method to RevisionableStorageInterface then we don't have to check whether the entity type is revisionable :).

  4. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +189,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +      // 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)) {
    +        $keep_untranslatable_fields = FALSE;
    +      }
    
    +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
    @@ -23,6 +42,26 @@
    +   * @param bool|null $keep_untranslatable_fields
    

    Let's just define the default value of the parameter $keep_untranslatable_fields to be FALSE and move the comment to the documentation of the parameter.

  5. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +189,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +        foreach ($definitions as $field_name => $definition) {
    +          if (!$keep_untranslatable_fields || $definition->isTranslatable()) {
    +            $new_revision_translation->set($field_name, $default_revision_translation->get($field_name)->getValue());
    +          }
    +        }
    

    We could exchange this with something more easy to understand:

    $sync_fields = keep_untranslatable_fields ? default_revision_translation->getFields() : default_revision_translation->getTranslatableFields() ;
    foreach ($sync_fields as $field => $items) {
      ...
    }
    
  6. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +189,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +      // Preserve a reference to the current revision, as the new revision would
    +      // appear as being originated from the default revision otherwise.
    +      $new_revision->set($this->entityType->getKey('revision'), $entity->getLoadedRevisionId());
    +      $new_revision->updateLoadedRevisionId();
    

    This is not needed anymore as now we start with a clone of the entity.

  7. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +189,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +      // Populate the "original" property with the current values, given that
    +      // the new revision is not stored anywhere.
    +      $new_revision->original = clone $new_revision;
    

    I am not sure if I understand why this is needed. Could you please elaborate a bit more on it?

  8. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +189,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +    // 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);
    

    What exactly does mean "to be sure this revision can be related to the correct translation"?

  9. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
    @@ -23,6 +42,26 @@
    +   * When dealing with a translatable entity, this will merge the default
    +   * revision with the latest affected revision for the entity active language.
    

    Actually the method will merge any other translations expect the current one of the default revision into the current entity no matter if the current entity is the latest affected revision or something else. So this piece of the comment is a little bit confusing.

plach’s picture

Thanks for the review! Here is a new patch and some replies:

1/2: The point of ::hasTranslations() is checking whether there are stored translations, that's why it's on the storage class. In this case we are not interested in knowing whether the passed entity object has translations, we want to know whether there's any stored translation for that entity. The logic in the ::createRevision() method assumes to deal with one changed translation at a time, for pending revisions, so if an object with a non-stored translation is passed, and that is not the active translation, we are in a non-supported scenario. Unless the plan is to store the passed entity object first and then save the new revision, in which case I guess things should work, but I didn't try. Anyway, I was hoping that since the method is on the storage it would be clear that it refers to stored data, but maybe ::hasStoredTranslations() would be a better name?
3: RevisionableStorageInterface should not know the concept of translations (separation of concerns), so far we have been putting methods supporting revisions + translations in ContentEntityStorageInterface. See also the related comment at #2926483-36: Add API methods for determining whether an entity object is the latest (translation-affecting) revision.
4: Sorry, that's not obvious at all, but that code is a placeholder for the logic added in #2878556: Ensure that changes to untranslatable fields affect only one translation in pending revisions. I added a @todo to make it clearer.
5: Good point! Fixed.
6: Unfortunately it is still needed: if we are not keeping untranslatable fields, the revision ID is overwritten with the default revision value. We could add a check in the loop to always skip the revision ID, but I thought it was cleaner this way.
7: Sure thing: if we don't populate the original property, it will be populated by the storage, which would load the default revision. However if it loaded the correct revision, it might incorrectly mark all non-active translations as affected, because the default revision values might differ from the loaded revision ones. We can remove this line but it will have to be restored once we fix #2833084: $entity->original doesn't adequately address intentions when saving a revision and friends. Additionally I think it's useful to determine whether any value in the new revision object was changed, especially in non-active translations, that are not supposed to be changed. This would be mostly useful if we wanted to perform some kind of validation around this logic.
8: I meant that since we are creating this new revision to perform changes on the active translation, it's important to store this information even if for some reason no actual change is performed.
9: You're right, that's a leftover of a previous approach I tried, fixed.

hchonov’s picture

1./2. - The method is checking only forward revisions therefore the name should be specifying this. Not every developer is aware of forward revisions and ::hasStoredTranslations will be confusing for such developers.

3. Ok, I agree.

4. I still don't understand the point of this piece of code :). Why don't we set the default value of the parameter here where we introduce the method?

5. Thanks.

6. I personally would prefer if we skip synching the field at all. All the fields we don't have to sync are -

  • entity keys: - id, revision, bundle, langcode, uuid
  • all the revision metadata keys

7. Ok, now I understand what you mean, but if you are going to save the entity as a default revision then original should be the currently default revision :). I would rather remove this piece of code here and fix this in the other issue which is dedicated to setting the right original entity.

8.

it's important to store this information even if for some reason no actual change is performed.

I am sorry, but I have to ask why is this important? :)

9. Thanks, I think that now the comment is more understandable :).

plach’s picture

New patch and more replies:

1/2: Addressed the issue as we discussed.
4: The logic in the followup cannot be assigned as a default, as it requires invoking a couple of methods, hence we need to know that the parameter was not explicitly passed (regardless of it being TRUE or FALSE) to be able to apply the default logic.
6: Done
7: Fair enough, I still think we should do something like that but I'm fine with discussing that in the issues fixing the original property.
8: If we have a revision with no affected translation, how can we tell in which language it should appear on the revision overview page?

catch’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
@@ -148,6 +149,39 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
+      if (!$result) {
+        $query = $this->getQuery()
+          ->condition($this->entityType->getKey('id'), $entity->id())
+          ->condition($this->entityType->getKey('default_langcode'), FALSE)
+          ->range(0, 1);
+
+        if ($entity->getEntityType()->isRevisionable()) {
+          $query->allRevisions();
+        }
+
+        $result = (bool) $query->execute();
+      }

This should have an accessCheck(FALSE). All the other queries in the patch look OK though.

Overall this is looking good to me.

plach’s picture

Fixed, thanks!

plach’s picture

Wrong interdiff

timmillwood’s picture

+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
@@ -23,6 +42,26 @@
+  /**
+   * 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\ContentEntityInterface $entity
+   *   The 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. Defaults to
+   *   FALSE.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   A new entity revision object.
+   */
+  public function createRevision(ContentEntityInterface $entity, $default = TRUE, $keep_untranslatable_fields = NULL);

Either $keep_untranslatable_fields is wrong or the docblock is. Should the default be NULL or FALSE? Seems like it should just be bool, and not have a null value. In the implementation it is set to FALSE if NULL, however this is changed in #2878556: Ensure that changes to untranslatable fields affect only one translation in pending revisions and another implementation using this interface may do something different.

plach’s picture

Discussed #30 with Tim and we decided to leave the default logic description for now, since it's going to change in the follow-up.

timmillwood’s picture

Thanks @plach looks good to me now.
Ready to RTBC once the dependency is in.

plach’s picture

Status: Needs review » Postponed

I'd like to give @hchonov a chance to review the latest changes, but no hurry given that the blocker is still not ready.

plach’s picture

Issue summary: View changes
plach’s picture

Title: [PP-1] Add an API to create a new revision correctly handling multilingual pending revisions » Add an API to create a new revision correctly handling multilingual pending revisions
Status: Postponed » Needs review
FileSize
33.74 KB
9.49 KB

Here is a reroll now that blocker was committed.

plach’s picture

Issue summary: View changes
plach’s picture

Minor PHP doc clean-up. Hopefully we move this back to RTBC now.

The last submitted patch, 35: entity-new_revision-2924724-35.patch, failed testing. View results

Status: Needs review » Needs work

The last submitted patch, 37: entity-new_revision-2924724-37.patch, failed testing. View results

plach’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 37: entity-new_revision-2924724-37.patch, failed testing. View results

plach’s picture

Status: Needs work » Needs review

Testbots--

gabesullice’s picture

Status: Needs review » Needs work
  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -148,6 +149,41 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
    +            $result = TRUE;
    +            break;
    

    Can you just return TRUE;? That would simplify the rest of this method.

  2. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -148,6 +149,41 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
    +        $result = (bool) $query->execute();
    

    Rather than type juggling, can you return !empty($query->execute());. I think that makes the intent clearer.

  3. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +202,91 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +    // translation and the other translations in the default revision. This
    +    // allows to create pending revisions that can always be saved as the new
    +    // default revision without reverting changes in other languages.
    

    "This permits the creation of pending revisions"

  4. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -166,6 +202,91 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    +      $skipped_field_names = array_flip($this->getRevisionTranslationMergeSkippedFieldNames());
    ...
    +        $sync_items = array_diff_key($sync_items, $skipped_field_names);
    

    These can be combined.

  5. +++ b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php
    @@ -27,4 +27,23 @@
    +   * the default revision may appear as not translated. This method allows to
    

    s/allows to determine/determines/g

  6. +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php
    @@ -0,0 +1,465 @@
    +   * The entity bundle info service.
    

    Nit: The entity type bundle info service.

plach’s picture

Status: Needs work » Needs review
FileSize
35.29 KB
9.06 KB

Thanks for the review! This should address #43 and a couple of issues I discovered while working on that.

1: Yep, the method body is short enough to make this a readability improvement.
2: I kept $result as it makes using the debugger easier.
3: Fixed
4: I combined only the two last lines, otherwise we would be needlessly invoking ::getRevisionTranslationMergeSkippedFieldNames() for each translation.
5: Fixed
6: Fixed

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
    @@ -148,6 +149,41 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
    +          if ($entity->getTranslationStatus($langcode)) {
    

    I also realized this is not correct, only existing translations should trigger an early return. Ideally also removed translations, but those are not returned by :: getTranslationLanguages().

  2. +++ b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php
    @@ -27,4 +27,23 @@
    +   * A revisionable entity can have translations in a pending revision, hence
    

    The contract for the translatable-only storage should not mention revisions. Those lines should be added in TranslatableRevisionableStorageInterface.

gabesullice’s picture

I combined only the two last lines, otherwise we would be needlessly invoking ::getRevisionTranslationMergeSkippedFieldNames() for each translation.

Ah, I'm with you now. I didn't like how "far" apart the variable assignment was from the usage, but now I see your reasoning.

amateescu’s picture

+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php
@@ -53,7 +54,13 @@ class EntityTestMulRev extends EntityTestRev {
+    $fields['not_translatable'] = BaseFieldDefinition::create('string')

How about renaming this field to non_mul_field to match the existing non_rev_field?

plach’s picture

effulgentsia’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/TranslatableStorageInterface.php
    @@ -27,4 +27,21 @@
    +  public function hasStoredTranslations(TranslatableInterface $entity);
    

    What's the purpose of this method existing on this interface? Looks to me like the only utility of this method is for TranslatableRevisionableStorageInterface only.

  2. +++ b/core/lib/Drupal/Core/Entity/TranslatableRevisionableStorageInterface.php
    @@ -7,6 +7,45 @@
    +  public function hasStoredTranslations(TranslatableInterface $entity);
    

    Are we sure we want this in the interface? If so, why? In the current patch, it looks like it could just as easily be a protected method of ContentEntityStorageBase. If there's future work that requires this to be in the interface, then I don't think the name makes it sufficiently clear that it queries across all revisions of $entity, not just $entity's revision. What about something like hasTranslationsInAnyRevisions() or isAnyRevisionTranslated()? If we take it out of the interface and make it a protected method, then I'm less concerned about the name.

plach’s picture

Well, I thought that method might have been useful in the follow-up work dealing with translation removal, however I'm not 100% sure about that yet. We can certainly make it protected for now and "publish" it later, if needed. The only downside is we would lose test coverage that way. Unless we implement something like this test helper to deal with that: https://3v4l.org/rK9r7.

timmillwood’s picture

I quite like the idea of adding invokeProtectedMethod() in a trait (or the test class itself). It seems a nice way to keep the methods protected for now whilst testing. One of the biggest issues in D8 is too many public methods, so would be nice to make as much protected as possible, then change later if needed. The other option could be to @internal everything.

effulgentsia’s picture

Don't we already have examples of tests that test protected methods by subclassing within the test and making it public in the subclass? If that's not possible for entity storage classes for some reason, I'd be fine with losing the test coverage of that method for now and adding it in a followup with whatever technique makes the most sense.

plach’s picture

Done!

RTBC anyone? :)))

timmillwood’s picture

+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
@@ -150,9 +150,23 @@ protected function initFieldValues(ContentEntityInterface $entity, array $values
+   * Checks whether the specified entity has stored translations.
...
-  public function hasStoredTranslations(TranslatableInterface $entity) {
+  protected function isAnyRevisionTranslated(TranslatableInterface $entity) {

The docblock says "has stored translations", but the method name has been changed to isAnyRevisionTranslated(), should the docblock be updated to similar wording?

Other than that nitpick, looks good to me.

plach’s picture

plach’s picture

timmillwood’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @plach!

hchonov’s picture

+++ b/core/modules/system/tests/modules/entity_test/src/EntityTestStorage.php
@@ -0,0 +1,20 @@
+class EntityTestStorage extends SqlContentEntityStorage {
...
+  public function isAnyRevisionTranslated(TranslatableInterface $entity) {
+    return parent::isAnyRevisionTranslated($entity);
+  }

Hm, this new storage class has no logic beside just passing the call to the parent. What is it then needed for?

hchonov’s picture

Ouh I got it now... It is because the parent method is protected and in the new storage class it is being made public, so that it could be tested ... Well as there is only one test testing that method, I think it would be better to use reflection there to access and execute the protected method instead of adding a new storage class.

plach’s picture

I suggested exactly that in #49, but apparently that's not how we've been doing things so far (see #51). I guess switching to a reflection-based approach would need its own discussion, but obviously I'm +1 on the idea.

hchonov’s picture

but apparently that's not how we've been doing things so far (see #51).

Actually we have a lot of places where we use reflection to test non-public methods. Some of them are:

  • \Drupal\Tests\Core\DrupalKernel\DrupalKernelTest::testTrustedHosts() is testing \Drupal\Core\DrupalKernel::setupTrustedHosts()
  • \Drupal\Tests\rest\Unit\EntityResourceValidationTraitTest is testing \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
  • \Drupal\Tests\Component\Plugin\Discovery\DiscoveryTraitTest::testDoGetDefinition() is testing \Drupal\Component\Plugin\Discovery\DiscoveryTrait::doGetDefinition()
  • \Drupal\Tests\Core\Config\Entity\ConfigEntityBaseUnitTest::testAddDependency() is testing \Drupal\Core\Config\Entity\ConfigEntityBase::addDependency()
  • ...

I consider the entity_test module already overpopulated with stuff and I personally would add new classes only if they are really required.

plach’s picture

I consider the entity_test module already overpopulated with stuff and I personally would add new classes only if they are really required.

I agree, however I'm not sure how to proceed here: I'd hate to lose test coverage, even temporarily, but adding that in a follow-up seems the only way forward to accommodate both #51 and #60.

Edit: unless we make the method public again, which is not probably a good strategy.

plach’s picture

effulgentsia’s picture

Status: Reviewed & tested by the community » Needs work

Looks like #62 is failing tests on SQLite :(

plach’s picture

plach’s picture

Status: Needs review » Reviewed & tested by the community

SQLite fixed, back to Alex.

plach’s picture

Assigned: plach » effulgentsia
hchonov’s picture

Spoke with @effulgentsia: he is fine with using reflection without an helper trait, so here's the updated patch.

I am really happy that we have a consensus here :).
@plach++
@effulgentsia++

effulgentsia’s picture

Ticking credit boxes for reviewers. Thank you for all the great reviews!

  • effulgentsia committed 2909c97 on 8.5.x
    Issue #2924724 by plach, hchonov, timmillwood, Gábor Hojtsy, amateescu,...
effulgentsia’s picture

Title: Add an API to create a new revision correctly handling multilingual pending revisions » [Needs change record] Add an API to create a new revision correctly handling multilingual pending revisions
Assigned: effulgentsia » Unassigned
Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record
FileSize
2.3 KB

Pushed to 8.5.x to unblock progress towards #2860097: Ensure that content translations can be moderated independently.

I think this needs a change record for the interface expansion, since it will require implementations not extending from ContentEntityStorageBase to implement the new method, so "Needs work" for that.

I also applied this interdiff on commit to fix what phpcs was flagging as a coding standards violation.

plach’s picture

Title: [Needs change record] Add an API to create a new revision correctly handling multilingual pending revisions » Add an API to create a new revision correctly handling multilingual pending revisions
Issue summary: View changes
Status: Needs work » Fixed
plach’s picture

Issue tags: -Needs change record

  • Gábor Hojtsy committed db9c0e6 on 8.5.x
    Issue #2939107 by plach, Wim Leers: Follow-up for #2924724: Clean up the...

  • Gábor Hojtsy committed 6538805 on 8.6.x
    Issue #2939107 by plach, Wim Leers: Follow-up for #2924724: Clean up the...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.