Problem/Motivation

When you have a node translated into two languages (p.e. English and German), both „versions" can be edited separately. But revisions are created/stored across all language versions and not independently.

This causes a situation like this:
The English editor (uid 1) creates a node and creates 3 revisions of it afterwards by editing the node 3 times.
Now the German editor (uid 2) creates a translation from this node and starts to work on it for several days and creates 5 revisions.
In the meantime the English editor also creates a new revision just to fix a small typo.

The revision table basically looks like this:

nid     vid     revision_uid
1       1       1
1       2       1
1       3       1
1       4       2
1       5       2
1       6       2
1       7       1               <= The typo fix by the English editor
1       8       2
1       9       2

Now the German editor decides to revert his latest 2 changes and to go back to vid 6.
In this case he not only reverts his changes in the German translation but the typo fix in the English translation as well!

This did not happen in Drupal 7 using core’s content translation!

Proposed resolution

Reverting a revision just for affected translations is technically possible in the current architecture in core by the following strategy.

  1. Use the latest revision of an entity as base.
  2. Remove the translations that should be reverted to an older revision.
  3. Re-add the affected translations loaded from the targeted old revision.

Therefor a new boolean field is required to indicate if a translation is modified within a revision.

Remaining tasks

Commit the existing Patch.

User interface changes

None in this patch. But based on this patch it will be possible to build a better UI to give the user more control over reverting translations and revisions. The follow up issue for that is #2465907: Node revision UI reverts multiple languages when only one language should be reverted.

API changes

Drupal\Core\Entity\ContentEntityInterface now defines 3 additional methods that are implemented by Drupal\Core\Entity\ContentEntityBase:

interface ContentEntityInterface extends \Traversable, FieldableEntityInterface, RevisionableInterface, TranslatableInterface {
  /**
   * Determines if the current translation of the entity has unsaved changes.
   *
   * If the entity is translatable only translatable fields will be checked for
   * changes.
   *
   * @return bool
   *   TRUE if the current translation of the entity has changes.
   */
  public function hasTranslationChanges();

  /**
   * Marks the current revision translation as affected.
   *
   * @param bool $affected
   *   The flag value.
   */
  public function setRevisionTranslationAffected($affected);

  /**
   * Checks whether the current translation is affected by the current revision.
   *
   * @return bool
   *   TRUE if the entity object is affected by the current revision, FALSE
   *   otherwise.
   */
  public function isRevisionTranslationAffected();

}

Module developers now have to define the new base field on translatable and revisionable entities, for example like
Drupal\node\Entity\Node:

public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
  ...
  $fields['revision_translation_affected'] = BaseFieldDefinition::create('boolean')
    ->setLabel(t('Revision translation affected'))
    ->setDescription(t('Indicates if the last edit of a translation belongs to current revision.'))
    ->setReadOnly(TRUE)
    ->setRevisionable(TRUE)
    ->setTranslatable(TRUE);
  ...
}
CommentFileSizeAuthor
#155 et-node_revisions-2453153-155.patch39.67 KBplach
#155 et-node_revisions-2453153-155.interdiff.txt3.86 KBplach
#144 et-node_revisions-more_tests-2453153-144.patch39.49 KBmkalkbrenner
#144 141-144-interdiff.txt2.13 KBmkalkbrenner
#141 et-node_revisions-more_tests-2453153-141.patch39.53 KBmkalkbrenner
#141 138-141-interdiff.txt4.82 KBmkalkbrenner
#138 et-node_revisions-2453153-138.patch36.33 KBplach
#138 et-node_revisions-2453153-138.interdiff.txt2.28 KBplach
#137 et-node_revisions-2453153-137.test.patch3.83 KBplach
#137 et-node_revisions-2453153-137.patch36.33 KBplach
#137 et-node_revisions-2453153-137.interdiff.txt36.42 KBplach
#131 125-131-interdiff.txt4.31 KBmkalkbrenner
#131 2453153_131.patch22.75 KBmkalkbrenner
#131 129-131-interdiff.txt1.61 KBmkalkbrenner
#129 2453153_129.patch22.67 KBmkalkbrenner
#129 125-129-interdiff.txt4.36 KBmkalkbrenner
#125 2453153_125.patch21.7 KBmkalkbrenner
#125 122-125-interdiff.txt7.59 KBmkalkbrenner
#122 2453153_122.patch22.22 KBmkalkbrenner
#122 105-122-interdiff.txt3.38 KBmkalkbrenner
#120 105-119-interdiff.txt3.47 KBmkalkbrenner
#119 2453153_119.patch22.2 KBmkalkbrenner
#105 2453153_105.patch20.25 KBmkalkbrenner
#103 2453153_103.patch19.43 KBmkalkbrenner
#103 101-103-interdiff.txt12.04 KBmkalkbrenner
#101 2453153_101.patch10.56 KBmkalkbrenner
#99 2453153_99.patch10.85 KBmkalkbrenner
#97 2453153_97.patch10.8 KBmkalkbrenner
#95 2453153_95.patch10.4 KBmkalkbrenner
#93 revert-log.pdf70.02 KBplach
#92 mulrev-revert-pointer.pdf26.2 KBplach
#92 mulrev-revert-flag.pdf26.41 KBplach
#87 2453153_87.patch9.44 KBmkalkbrenner
#87 86-87-interdiff.txt1.58 KBmkalkbrenner
#86 2453153_86.patch9.31 KBmkalkbrenner
#63 2453153_63_based_on_2478459_57.patch55.26 KBmkalkbrenner
#63 2453153_63_do-not-test.patch30.19 KBmkalkbrenner
#61 2453153_61_based_on_2478459_57.patch54.75 KBmkalkbrenner
#61 2453153_61_do-not-test.patch29.69 KBmkalkbrenner
#57 2453153_56_do-not-test.patch26.36 KBmkalkbrenner
#49 2453153_revision_alternatively_revert_one_language_only_49_based_on_2428795_translatable_changed_time_53.txt24.08 KBmkalkbrenner
#45 2453153_revision_alternatively_revert_one_language_only_45.patch28.06 KBmkalkbrenner
#34 2453153_revision_alternatively_revert_one_language_only_34.patch27.88 KBmkalkbrenner
#29 2453153_revision_alternatively_revert_one_language_only_28.patch27.71 KBmkalkbrenner
#14 2453153_revision_alternatively_revert_one_language_only_14.patch23.14 KBmkalkbrenner
#10 Revisions_for_teste_english_no_new_revision___sb95accc6102622a_s3_simplytest_me.jpg758.62 KBschnitzel
#6 2453153_revision_alternatively_revert_one_language_only_6.patch22.41 KBmkalkbrenner
#5 2453153_revision_alternatively_revert_one_language_only_5.patch23.67 KBmkalkbrenner
revision_alternatively_revert_one_language_only.patch12.39 KBmkalkbrenner

Comments

gábor hojtsy’s picture

Issue tags: +D8MI, +language-content, +sprint
mkalkbrenner’s picture

andypost’s picture

  1. +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,158 @@
    +    $language_name = t(\Drupal::languageManager()->getLanguageName($this->langcode));
    ...
    +    $language_name = t(\Drupal::languageManager()->getLanguageName($this->langcode));
    

    manager should be injected

  2. +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,158 @@
    +    return t('Are you sure you want to revert to the revision from %revision-date for %language?', array('%revision-date' => format_date($this->revision->getRevisionCreationTime()), '%language' => $language_name));
    

    $this->t()

  3. +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,158 @@
    +    $form_state->setRedirect(
    +      'entity.node.version_history',
    

    could be exposed as link template

  4. +++ b/core/modules/node/src/NodeStorage.php
    @@ -29,6 +29,13 @@ public function revisionIds(NodeInterface $node) {
    +  public function revisionLanguages(NodeInterface $node) {
    

    needs @inheritdoc

+++ b/core/modules/node/src/Controller/NodeController.php
@@ -162,16 +162,22 @@ public function revisionOverview(NodeInterface $node) {
-    $vids = $node_storage->revisionIds($node);
+    $vids = $node_storage->revisionLanguages($node);

revisionIds() is only now used by core/modules/quickedit/src/Tests/QuickEditLoadingTest.php:153

mkalkbrenner’s picture

1. manager should be injected
=> done. I'll upload a new patch after the following questions have been answered

2. $this->t()
3. could be exposed as link template
=> I used class NodeRevisionRevertForm as template. These things are implemented the same ways as in the existing class. Should my patch fix both or should that be a separate patch?
BTW can you point me to an example for such "link templates"?

4. needs @inheritdoc
=> done

5. RevisionIds() is only now used by core/modules/quickedit/src/Tests/QuickEditLoadingTest.php:153
=> I'm not sure if this function should be removed. What do others think about it?

mkalkbrenner’s picture

I created a new patch that contains all suggestions from #3.

mkalkbrenner’s picture

I added some tests to the patch. What else is needed to get this in?

pinolo’s picture

Status: Needs review » Reviewed & tested by the community

I have tested it successfully and code looks good.

alexpott’s picture

Status: Reviewed & tested by the community » Needs review

I would be great to have input from either @plach or @berdir

alexpott’s picture

Assigned: Unassigned » plach

@berdir in IRC said "plach is probably more qualified to review that" ... assigning to @plach

schnitzel’s picture

Issue summary: View changes
Status: Needs review » Needs work
StatusFileSize
new758.62 KB

wow, very nice idea and solution to a definitely big problem.

I tested the UI and found one small issue:

I would not show any language in the current revision, as it could imply that the current revision only has one language, which is actually not true. And as we cannot do anything with this revision I would not show anything.

Another problem I've found:
the UI implies, that for every change we create a new revision. But you can edit nodes without adding a new revision:
1. Have a node in DE and EN (Revision Nr 2)
2. Edit EN, create a new revision (revision Nr 3)
3. Edit DE, do not create a new revision
4. Edit EN, create a new revision (revision Nr 4)
5. Edit EN, create a new revision (revision Nr 5)
Now the UI shows the Revision Nr 4 as "Language: English", but it also contains some changes to German.
If the user now reverts to version Nr. 3, his german changes are not lost (because we only revert the English translation) but it's still confusing as the revision list implies there are only english changes.
But actually I don't really know how to fix that, other systems like Workbench in D7 force the creation of new revisions whenever you like to have some workflow based systems. Would there be a way in the example above to show "English, German" in the language column for Revision 4?

plach’s picture

The suggested approach looks sensible to me, my main concern is that in the current implementation we are changing the meaning of the langcode column in the revision table, which is meant to store the entity default language per revision. This would work only for the SQL entity storage, but the Mongo storage would save only one value for the langcode field, hence the current code would break.

To address this and #10, we could define an additional revision_translation_changed field (name TBD), which would hold the entity modification timestamp per language. Translation would need to be always enabled for this field, Content Translation would take care of that by not listing it in the settings UI, as it does for its own fields.

The new field would be stored in the field data and field revision tables as usual, and would allow us to track which translations were edited for each revision by simply comparing the revision translation timestamps with the revision timestamps. The drop-button in the revision list could be kept as it is, and in the confirmation form we could display a list of translations to be reverted with a checkbox for each one. I'd make them all checked by default.

Here's a very quick code review:

  1. +++ b/core/modules/node/src/Controller/NodeController.php
    @@ -162,16 +174,22 @@ public function revisionOverview(NodeInterface $node) {
    +      $language_name = $this->t($this->languageManager->getLanguageName($langcode));
    
    +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,175 @@
    +    $language_name = $this->t($this->languageManager->getLanguageName($this->langcode));
    

    Language data is stored in config, hence to translate language names we should rely on config translation rather than string translation.

  2. +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,175 @@
    +    $old_revision = $this->nodeStorage->loadRevision($node_revision);
    +    $latest_revision = $this->nodeStorage->load($old_revision->id());
    +    $tmp_revision = NULL;
    +
    +    if ($old_revision->language()->getId() == $langcode) {
    +      // Revert the default language.
    +      $tmp_revision = $old_revision;
    +      $translation_languages = $latest_revision->getTranslationLanguages(FALSE);
    +      foreach (array_keys($translation_languages) as $translation_langcode) {
    +        if ($tmp_revision->hasTranslation($translation_langcode)) {
    +          $tmp_revision->removeTranslation($translation_langcode);
    +        }
    +        $tmp_revision->addTranslation($translation_langcode, $latest_revision->getTranslation($translation_langcode)->toArray());
    +      }
    +    }
    +    else {
    +      // Revert a translation.
    +      $tmp_revision = $latest_revision;
    +      $tmp_revision->removeTranslation($langcode);
    +      $tmp_revision->addTranslation($langcode, $old_revision->getTranslation($langcode)->toArray());
    +    }
    +
    +    $this->revision = $tmp_revision->getTranslation($langcode);
    

    Why do we need all this processing in the build phase? Wouldn't it make more sense to perform it on submit?

  3. +++ b/core/modules/node/src/Form/NodeRevisionRevertLanguageForm.php
    @@ -0,0 +1,175 @@
    +    // The revision timestamp will be updated when the revision is saved. Keep the
    +    // original one for the confirmation message.
    
    +++ b/core/modules/node/src/NodeStorageInterface.php
    @@ -24,10 +24,26 @@
    +   * @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0.
    +   *   Use array_keys(NodeStorageInterface::revisionLanguages(NodeInterface $node)) instead.
    
    +++ b/core/modules/node/src/Tests/NodeTranslationRevisionTest.php
    @@ -0,0 +1,150 @@
    +      // Iterate through all the methods in this class, unless a specific list of
    +      // methods to run was passed.
    ...
    +          // Avoid to run these tests implemented in NodeTranslationUITest twice.
    

    80 chars limit exceeded

  4. +++ b/core/modules/node/src/NodeStorage.php
    @@ -32,6 +32,16 @@ public function revisionIds(NodeInterface $node) {
    +      'SELECT vid, langcode FROM {node_revision} WHERE nid=:nid ORDER BY vid',
    

    We usually have spaces around operators.

  5. +++ b/core/modules/node/src/NodeStorageInterface.php
    @@ -24,10 +24,26 @@
    +   * Returns a list of node revisions and their languages for a specific node.
    

    Missing blank line after introduction line.

  6. +++ b/core/modules/node/src/Tests/NodeTranslationRevisionTest.php
    @@ -0,0 +1,150 @@
    +   * @inheritdoc
    

    Missing braces

plach’s picture

Assigned: plach » Unassigned

Btw, it's true this functionality was available in D7 but this almost looks like a new feature to me.

miro_dietiker’s picture

Uff.. The title focusses on reverting a single language.
I would understand if one argues, this is a new D8 feature, as it was never seen in D8 before.

However, note that the revisions tab / looking at previous revisions is currently unusable in a setup with multiple languages enabled where translations happen.
The user is not aware of his current language context, but all output only represents the current language. If i compare two revisions (given that the newer added a translation into a new language) with the UI set to a different language, i see no change at all between the revisions.

Yes, revisions can not be limited to one language only. The best we can do with current design, is outline what languages are affected per revision. This, however, either needs comparision, or a per-language-per-revision timestamp as suggested by plach. We could then outline the language affected in the UI.
Alternatively, we could also just add a flag per revision per language that is 1 if a field changed in that language.

In my opinion, we should output the affected language column if we can.
As soon as you start offering this, a good UI would allow a user to filter revisions for a specific language. And similarly, allow reverting a certain language only.

The flag approach would make us most flexible since it can be used reliably for output without significant processing. It needs a lookup to the previous revision when saving though. This is permanent overhead though (for every save), since we never know if there are any previous revisions... See also #808730: Show the Revisions tab/page even when only one revision exists.

Yes, we just started to build workarounds for issues that are created because we did not manage to limit a revision to one language only. We need to be careful that this problem and related workarounds are not adding more complexity than fixing the original problem.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new23.14 KB

I created a new patch that covers the formal findings from plach in #11.
BTW it's funny that most of the formal issues already exist in the current version of the node module in core. I will create a separate issue for that.

I second miro_dietiker that the issue discussed here is not a "new feature". Drupal is still a CMS. And it's a fundamental requirement of an CMS that supports translations and revisions to have revisions per translation (or at least a UI that pretends it) - as it existed in prior versions of Drupal.
From my point of view there's a big mistake in the architecture regarding revisions and translations - like miro_dietiker described.

But I think it's to late in the game to apply fundamental changes to revisions in 8.0.x branch. My goal is to introduce an usable solution for 8.0.x, based on the data that already exists and without increasing the complexity too much.

I'm now looking at Schnitzel's suggestions having plach's commments in mind regarding timestamps, fields, etc.
I hope to provide an accordingly extended patch soon ...

mkalkbrenner’s picture

Status: Needs review » Needs work
mkalkbrenner’s picture

I thought about a field to store translation specific timestamps on changes.
I recognized the columns "changed" in the tables "node_field_data" and "node_field_revision". My first assumption was that this one could be simply used for our needs. But I realized that there's always the same value stored across all languages - no matter which translation has been changed really.

There's a corresponding bug report already:
#2428795: Translatable entity 'changed' timestamps are not working at all

Can anyone provide feedback on that one? Is it a bug or feature?

plach’s picture

I would understand if one argues, this is a new D8 feature, as it was never seen in D8 before.

I second miro_dietiker that the issue discussed here is not a "new feature".

One clarification: I said this «almost looks like a new feature» because fixing this requires some significant UI changes. That's why I was trying to suggest an approach minimizing the impact on the UI (and why I didn't change the category to "feature" and postponed this to 8.1.x).

[...]
In my opinion, we should output the affected language column if we can.
As soon as you start offering this, a good UI would allow a user to filter revisions for a specific language. And similarly, allow reverting a certain language only.

These are all good points, and UX improvements are not frozen yet, so something like this would still be feasible. However It would be good to get some advice from the UX folks. What about providing a minimal fix for the bug reported here, that is preserving unaffected revision translations during reverts, and open a follow-up to implement a better UI?

Not sure about the flag approach, timestamps wouldn't require additional processing on save, I think, and determining which translations were affected for each revision should be feasible in a single iteration, while fetching revisions from the DB, so it shouldn't be a big overhead.

Yes, we just started to build workarounds for issues that are created because we did not manage to limit a revision to one language only. [...]

I'd be happy to be proved wrong, but IMHO the current approach (revision translations) is the only viable one, if we want to keep the complexity of the Entity API inside reasonable limits. Having translation revisions with the current architecture, where a single entity has multiple language variants, would mean having per language revision ids, and getting rid of per-language ids is one of the main reasons I proposed the translatable fields model in the first place. This is certainly one of the cases clarifying that picking a single translation model forces us to accept some tradeoffs, however all considered I think pros still outweigh cons. The solution proposed by @mkalkbrenner is simple and elegant and, once it's coupled with a proper UI to support it, I don't think we will have significant regressions in the user-facing functionality.

From my point of view there's a big mistake in the architecture regarding revisions and translations - like miro_dietiker described.

From my POV it's a tradeoff and not a mistake, but I'm not very interested in arguing about this, unless you can suggest an alternative solution that fixes this use case and keeps all the rest working, in which case I'm all ears :)

I thought about a field to store translation specific timestamps on changes.
I recognized the columns "changed" in the tables "node_field_data" and "node_field_revision".
My first assumption was that this one could be simply used for our needs. But I realized that there's always the same value stored across all languages - no matter which translation has been changed really.

The reason why I proposed a separate field is twofold:

  • We need to support any translatable and revisionable entity type, not just nodes, so we cannot rely on the changed field. I expect the revision UI to be generalized much like the translation UI and be applied to any revisionable entity type sooner or later, so we need to be prepared for that, even if Node is the only entity type currently exposing a revision UI, at least in core.
  • The changed field might or might not be translatable (#2428795: Translatable entity 'changed' timestamps are not working at all looks like a bug to me), but we need a field that's always marked as translatable in this case.
mkalkbrenner’s picture

@plach: Thanks for your explanations. I think we are basically of the same opinion.

I now have a concept in mind how to implement the missing parts. But to avoid more redundancy in the code and database it would be nice if you can have a look at my comment #3 on #2428795: Translatable entity 'changed' timestamps are not working at all.

miro_dietiker’s picture

We need to support any translatable and revisionable entity type, not just nodes

We started a first approach are pretty near to to build a revision tab for all entities in the diff module. yay!
#2452523: Offer a revisions tab for all entities

This is certainly one of the cases clarifying that picking a single translation model forces us to accept some tradeoffs

There are many relevant trade offs, as pointed out in my blogpost. The most concerning one for me is that with every single update of a language, all translations are written. This simply results in severe scaling / performance issues with platforms containing many languages. Combined with inline editing and enabled revisioning, this is going to be fun. The current cross language locking (if multiple translators add translations at the same time) is still circumventable technically.

gnindl’s picture

A customer's perspective

On osce.org we are working with about 100,000 entities in default language English and entity translations in over 50 languages. Translations are usually done by separate editors, not working in parallel, but in general after the source translation - which may also be Russian, Armenian etc. - has been completed. There is no link or common revision between translations, each translation has its own workflow, pitfalls, false friends and typos.

Having dependent revisions in Drupal 8 would force us to think to upgrade to another CMS. IMHO the demand for such a feature is very strange. I assume that people who have only worked with a single language, e. g. English, can come up with such an idea.

Guys, please don't overdo things here. Multilingualism has matured a lot in Drupal 6 & 7, just stick to it.

mkalkbrenner’s picture

@gnindl: Thanks for your comment from a user's / customer's perspective.

My organization faces exactly the same situation, except that the number of translations is lower ;-)

The current behavior of the drupal 8 core regarding translations and revisions will be a show stopper / blocker for any migration from multilingual drupal 6 or 7 installations having multilingual editorial workflows - like the osce and my organization. That's why I created this issue to address at least one problem miro_dietiker discussed in his blog post.
For sure, scaling and performance issues have to be addressed as well, but I recommend to do that in a second step.

berdir’s picture

As @plach said, revisions per language would re-introduce per-language identifiers, which come with their own problems.

If you have 50 translations, then you will have different problems. All 50 translations are stored and loaded as part of the same entity. Obviously, that would be a huge amount of data.

Yes, the new translation model in core doesn't really work for that. It doesn't have to. There is AFAIK already a project that wants to support a translation model that is similar to the old translation module and uses translation sets. If that works better for your use cases, use that. Problem solved.

There is no chance that the core content translation module or content entity storage will change completely in Drupal 8.

mkalkbrenner’s picture

Yes, the new translation model in core doesn't really work for that. It doesn't have to. There is AFAIK already a project that wants to support a translation model that is similar to the old translation module and uses translation sets. If that works better for your use cases, use that. Problem solved.

OK, but what does that mean for the approach discussed here which is usable if you use only a few different languages?
I'm close to finish a new patch but I want to ensure that it makes sense to keep spending time on it.

gnindl’s picture

If you have 50 translations, then you will have different problems. All 50 translations are stored and loaded as part of the same entity. Obviously, that would be a huge amount of data.

That sounds scary and like a questionable design. It's like if you are just inviting a single friend to your party bringing his 50 cousins with her/him. It might be fun, but also crowded in your small apartment.

I am not aware of the code details and why this heavy loading has to be done, but why don't you introduce some lazy loading? Translation pointers which are loaded on demand? Or are you also storing 50 translations in a single Word document file?

If you want to group translations you should have translation sets. Overall I smell here an anti pattern God Object

cspitzlay’s picture

There is no chance that the core content translation module or content entity storage will change completely in Drupal 8.

I guess that includes any 8.x version not just 8.0.x.
All the more important that something like the above per-language reversion workaround gets in.

plach’s picture

We started a first approach are pretty near to to build a revision tab for all entities in the diff module. yay!

Great news :)

There are many relevant trade offs, as pointed out in my blogpost. The most concerning one for me is that with every single update of a language, all translations are written.

Well, we did not get to that yet, but having a proper Entity API baked in core will allow us to implement some cool optimizations. One of them is writing to the DB only the pieces of the entity that actually changed. This would solve your concern, I guess. IIRC in your article you were also pointing out that having many translations in a single entity object may result in memory issues. This might be trickier to address, but I think we could experiment with lazy loading entity fields per language. This would require some tweak to the entity caching logic, but I think it would be feasible. OTOH having all field data in all languages readily available is very useful to provide efficient language fallback, which we could not have with the node translation model, above all when listing entities. Hence I guess this could be an optional behavior, or even an alternative entity cache backend to be used only in very specific conditions.

The current cross language locking (if multiple translators add translations at the same time) is still circumventable technically.

Yep, we certainly need to improve that, but it's definitely feasible.

On osce.org we are working with about 100,000 entities in default language English and entity translations in over 50 languages. [...]
Having dependent revisions in Drupal 8 would force us to think to upgrade to another CMS.

Well, the current approach does not necessarily imply having dependent revisions, as @mkalkbrenner has just demonstrated with his patches. We simply did not get to make every single bit work yet. Help is more than welcome.

I assume that people who have only worked with a single language, e. g. English, can come up with such an idea.

Actually I worked almost only on multilingual projects for many years, however TBH none of them had 50 languages installed. OTOH this scenario is definitely not the most common, so not the one the default Drupal configuration should target. I'd like to point out that the current system is fully compatible with the D6/D7 translation sets approach: reimplementing it can be done in one working day, and AFAIK the Translation project aims to do exactly that. If even with every possible optimization, the core approach did not fit your use case, node translation will still be there to serve you and anyone else needing it :)

Guys, please don't overdo things here. Multilingualism has matured a lot in Drupal 6 & 7, just stick to it.

Thanks for the suggestion, I'm sure D8MI will benefit a lot from it.

OK, but what does that mean for the approach discussed here which is usable if you use only a few different languages?

I don't think so: what we have in core is the evolution of the D7 Entity Translation project, that is being used right now in very high profile sites with many languages installed. I'm confident that most of the issues we have identified can be successfully solved.

That sounds scary and like a questionable design. [...]
I am not aware of the code details [...]
If you want to group translations you should have translation sets.
Overall I smell here an anti pattern God Object

I'm trying hard to take this comment seriously, and consider it an attempt to provide a constructive criticism, but do you realize there are literally years of work and discussions behind the current implementation?

@all:

I don't think this is the right issue to rehash the revision/translation topic in general (let alone discussing the core content translation model). It would be great if we could create a meta issue listing all the concerns raised so far about the revision/translation topic, and possibly already create the related sub-issues.

Now can we please focus on implementing what's needed to fix the issue reported in the OP, possibly punting any UI improvement to a separate major task?

gábor hojtsy’s picture

I agree with @plach. Most components of the Drupal 8 multilingual system are not brand new at all, they evolved from existing Drupal 7 solutions. http://drupalwatchdog.com/volume-4/issue-1/drupal-babel has a comparison table of modules in Drupal 7 vs. core features we built-in. The content translation solution in Drupal 8 is especially built based on the experience we had with title module, workbench and entity_translation. I would be among the first to readily admit that not all aspects are fully covered, but (a) Drupal 8 is not yet released (b) now is your time to help out to cover your use cases. One way to help out is to help implement https://www.drupal.org/project/translation if you are interested in having a translation set option with different entities. We the entity translation method for core in part because that is more flexible in terms of configurability and especially compatibility with contributed modules (which historically had problems being compatible with both translation methods).

gnindl’s picture

Thanks for the clarification, fair enough.

It wasn't clear from your posts that the patch of mkalkbrenner and other performance improvements have a chance to be accepted. That communication problem being sorted out I will have a look if I can help out.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new27.71 KB

I think most of us agree to keep the current architecture and to improve it step by step.
Let's focus on the original specific topic I pointed out in this issue.

I created a new patch based on the suggestions and concerns of plach and Schnitzel.
It would be nice if to get some feedback on this one.

Status: Needs review » Needs work

Status: Needs work » Needs review

Status: Needs review » Needs work
mkalkbrenner’s picture

I noticed the problem of some existing tests with the last patch and I'm working on it ...

Nevertheless you can already provide feedback on the feature itself provided by the patch ;-)

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new27.88 KB

It's good to have tests :-)
My last patch contained a bug. Here's the correct version.

Status: Needs review » Needs work

Status: Needs work » Needs review
plach’s picture

Thanks! I will have a look to this later today

fabianx’s picture

Another possibility to solve this is to make revert fill in an edit form with the old revision of the entity. We use this approach (indirectly) for the CPS module (by design).

That allows to keep UI changes small and just bases reverts off an older entity. Still need a little special code to only replace those fields with data from the revision that is different and only for the loaded language, but then the language is explicit.

Just tossing in as a suggestion.

On the performance and memory concerns:

- Performance fixes in general are at the moment very viable and a lot of lazy loading could be done without any API changes, so can happen in any version of Drupal 8.
- Given the decoupled nature of Drupal 8, adding lazy loading for multiple languages within an entity is also feasible as all uses proper getter() and setter() now

Also optimizing storage writes is a feasible possibility without any API changes needed.

And given enough interests / use cases this might / will one day be implemented.

But of course if you chime in with help, it will get done faster.

mkalkbrenner’s picture

It turned out that there's a possible race condition caused by revision timestamps. That's why the test failed once in #35. In my environment the test fails in one of ten runs.

I already discussed that issue with cspitzlay and we have a solution. Stay tuned ...

plach’s picture

Status: Needs review » Needs work

That communication problem being sorted out I will have a look if I can help out.

Sounds good :)

Another possibility to solve this is to make revert fill in an edit form with the old revision of the entity. We use this approach (indirectly) for the CPS module (by design).

This is an interesting solution, however it's not very common to end up in an edit form when trying to revert a revision, at least in my experience. This could use some UX expert feedback, that's why I'm suggesting to provide a minimal fix here, so that the functionality is not completely broken, and discuss a more complete solution in a separate task.

  1. +++ b/core/modules/content_translation/src/ContentTranslationHandler.php
    @@ -104,6 +104,12 @@ public function getFieldDefinitions() {
    +    $definitions['content_translation_edited'] = BaseFieldDefinition::create('timestamp')
    

    As I mentioned in #2428795-9: Translatable entity 'changed' timestamps are not working at all, this field cannot be defined by the Content Translation module, because the API allows to create/update translations programmatically without needing CT to be installed. It should be provided by the Entity Manager like we do for the default_langcode field.

  2. +++ b/core/modules/content_translation/src/ContentTranslationHandler.php
    @@ -537,6 +543,7 @@ public function entityFormEntityBuild($entity_type, EntityInterface $entity, arr
         $metadata->setChangedTime(REQUEST_TIME);
    +    $metadata->setEditedTime(REQUEST_TIME);
    

    We don't need two methods, we just need to make ::setChangedTime() use the new field.

  3. +++ b/core/modules/content_translation/src/ContentTranslationMetadataWrapper.php
    @@ -75,6 +75,21 @@ public function setOutdated($outdated) {
    +  public function getEditedTime() {
    ...
    +  public function setEditedTime($timestamp) {
    
    +++ b/core/modules/content_translation/src/ContentTranslationMetadataWrapperInterface.php
    @@ -56,6 +56,24 @@ public function isOutdated();
    +  public function getEditedTime();
    ...
    +  public function setEditedTime($timestamp);
    

    As a consequence, these become redundant.

  4. +++ b/core/modules/content_translation/src/ContentTranslationHandler.php
    @@ -550,6 +557,26 @@ public function entityFormEntityBuild($entity_type, EntityInterface $entity, arr
    +      $metadata->setEditedTime($metadata->getCreatedTime());
    ...
    +      $metadata->setEditedTime(REQUEST_TIME);
    

    This should be handled by the entity storage handler directly, we cannot rely on a form workflow.

  5. +++ b/core/modules/node/src/Controller/NodeController.php
    @@ -162,7 +174,12 @@ public function revisionOverview(NodeInterface $node) {
    +    $header[] = $this->t('Revision');
    +    if ($node->isTranslatable()) {
    +      $header[] = $this->t('Languages Edited');
    +    }
    +    $header[] = $this->t('Operations');
     
    

    As I was saying above, please, let's try not to rush a new UI in. A dedicated issue where we can get proper UX reviews would have way more chances to obtain a better result (and be accepted by core committers). Can't we just assume that all the affected languages need to be reverted for now? This would allow us to skip any UI change.

  6. +++ b/core/modules/node/src/NodeStorageInterface.php
    @@ -28,6 +28,17 @@
    +  public function revisionAffectedTranslations(NodeInterface $node);
    

    We normally use a "get" prefix for this kind of methods, what about getAffectedRevisionTranslations()?

mkalkbrenner’s picture

I need some feedback on #2428795: Translatable entity 'changed' timestamps are not working at all comment #12.

Using a solution like I proposed for that bug, we don't need an additional timestamp here. In this case plach's points 1, 2, 3 and 4 from comment #40 will be obsolete.

plach’s picture

Created meta, feedback welcome.

plach’s picture

Title: Node revisions cannot be reverted per translation » [PP-1] Node revisions cannot be reverted per translation
plach’s picture

Status: Needs work » Postponed
mkalkbrenner’s picture

I noticed a small issue when creating screenshots for #2465907: Node revision UI reverts multiple languages when only one language should be reverted. Therefor I adjusted the patch. But this patch will not apply against head but beta9. A new patch against head is postponed until the related issues are fixed.

tstoeckler’s picture

The issue summary needs to be updated with the proposed resolution.

Specifically, I could not find an answer to the following question in the patch, although I must admit I didn't spend a super large amount of time on this: The normal changed field is already marked translatable, so why can't we use that and make sure that it's updated correctly for each translation. Semantically I don't see any difference, but again, I am probably missing something.

mkalkbrenner’s picture

@tstoeckler:
The "changed" is marked translatable but isn't translatable at the moment! Have a look at #2428795: Translatable entity 'changed' timestamps are not working at all.
Once that one is fixed I can upload a new patch here. I'm already working on it.

tstoeckler’s picture

Ahh OK thanks for that clarification, I will have a look.

mkalkbrenner’s picture

So here is an improved version of the patch from #45 which requires the patch #53 from #2428795: Translatable entity 'changed' timestamps are not working at all to be applied first.
(I'm doing that to enable others to test this stuff at the drupal dev days)

Screenshots are available at https://www.drupal.org/node/2465907#comment-9803885

mkalkbrenner’s picture

Status: Postponed » Needs review

Status: Needs review » Needs work
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Assigned: Unassigned » mkalkbrenner
StatusFileSize
new26.36 KB

I updated the patch to be applied on top of #2428795: Translatable entity 'changed' timestamps are not working at all. Therefore it could not be tested against 8.0.x-dev at the moment.
And I still want to polish some things, so the patch might be considered as a preview ATM ;-)

plach’s picture

Title: [PP-1] Node revisions cannot be reverted per translation » Node revisions cannot be reverted per translation
plach’s picture

Status: Postponed » Needs review
mkalkbrenner’s picture

Status: Needs review » Needs work

I need to adjust the patch to the final version of #2428795: Translatable entity 'changed' timestamps are not working at all. And ...

... I still want to polish some things, so the patch might be considered as a preview ATM ;-)

mkalkbrenner’s picture

I started working on the patch again.
The first step was to adjusted the patch the deal with the changes introduced by #2428795: Translatable entity 'changed' timestamps are not working at all.
In addition I addressed the TODO that has been left there:

class ChangedItem extends CreatedItem {
  ...
  public function preSave() {
    ...
    // @todo Knowing if the current translation was modified or not is
    //   generally useful. There's a follow-up issue to reduce the nesting
    //   here and to offer an accessor for this information. See
    //   https://www.drupal.org/node/2453153
    ...
  }
  ...
}

The second step was to remove some ugly parts of the code and to leverage the new postSave() function instead that will be introduced by #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks.

I uploaded two patches. the real one could not be tested without https://www.drupal.org/node/2478459#comment-9983133 being applied. Therefor I uploaded 2453153_61_based_on_2478459_57.patch which includes that patch in order to get the results from the test bot.

Status: Needs review » Needs work

The last submitted patch, 61: 2453153_61_based_on_2478459_57.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new30.19 KB
new55.26 KB

Hopefully fixed the tests

plach’s picture

Sorry, I had to read again the whole issue and the related ones to refresh my memory and I wasn't able to do a proper review.

I'm a bit confused because I thought we agreed to provide a minimal fix here, and instead the patch seems to do way more than what's strictly required to make revision reverts work with translations. It's clear you spent a lot of time dealing with real-life issues (this is hugely helpful, thanks :), but I'm having a hard time understanding the big picture.

I thought we agreed on using the (translatable) changed timestamp to figure out which translations were affected for each revision, but I guess the fact that two revisions can have the same timestamp kind of make this approach not viable. It would be great if you could summarize what's the currently implemented solution, so we can discuss it a bit before I go back reviewing the code.

A few things that concern me are:

mkalkbrenner’s picture

I thought we agreed on using the (translatable) changed timestamp to figure out which translations were affected for each revision, but I guess the fact that two revisions can have the same timestamp kind of make this approach not viable. It would be great if you could summarize what's the currently implemented solution, so we can discuss it a bit before I go back reviewing the code.

The approach is still the same. With #2428795: Translatable entity 'changed' timestamps are not working at all we introduced the required logic to detect changes per entity translation and set the changed timestamp accordingly.
But due to the nature of timestamps it's not safe to use their values for decisions. For example in our use case here it could happen that two revisions are created within the same second.
The solution to this problem is to leverage the (previously) internal logic of ChagedItem field and to store a value different from timestamps that represents the decision that has been taken by this logic. In our case the revision ID to which the change belongs.
This use-case has been foreseen by @catch in https://www.drupal.org/node/2428795#comment-9899597 :

knowing if the current translation was modified or not could be generally useful elsewhere? Could start with a protected helper then make it public later if that turns out to be useful.

That's exactly what I've done in the latest patch here. I moved the logic from ChangedItem::preSave() to the public function ChangedItem::hasEntityChanges(). That's what we all agreed on at #2428795: Translatable entity 'changed' timestamps are not working at all and why we left the corresponding TODO in that patch. This modification doesn't increase the complexity of the logic itself!
For a better DX I also added ChangedItem::hasEntityChangesAcrossTranslations(). But I'm fine to remove it if you don't like it.
For performance reasons I introduced ChangedItem::hasChangedOnPreSave(), because it makes no sense to run the logic twice to get the result of the logic elsewhere during save. Again, the stored timestamp value itself is not reliable.
To store such a reliable value to complete all the data that is required to solve #2465907: Node revision UI reverts multiple languages when only one language should be reverted I introduced the small ContentTranslationRevisionPointerItem field type. Thanks to @plach's patch #57 for #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks it's possible to ask for the decision that has been taken by ChangedItem::preSave() within ContentTranslationRevisionPointerItem::postSave() using ChangedItem::hasChangedOnPreSave() in a performant way.

The patch in #63 still includes the UI part and the UI test that already existed in all earlier versions of this patch to prove that my approach works. But I'm fine to remove all the UI stuff here and move it into a separate patch for #2465907: Node revision UI reverts multiple languages when only one language should be reverted.
The remaining patch here will then only consist of the clean-up / improvement of the ChangedItem field type and the addition of the ContentTranslationRevisionPointerItem by ContentTranslation. And a new test on the API level.

We are relying on a field provided by Content Translation, but this functionality needs to work even if CT is not enabled, as translations can be created programmatically.

I choose ContentTranslation because that's where it's ensured that a ChangedItem is added to an entity. If we want to move the revision pointer to ContentEntity we need to be consequent and require a ChangedItem at this layer as well if the entity is revisionable.

plach’s picture

@mkalkbrenner:

Thanks for the very detailed explanation :)

I had another look to the last patch and manually tested it to get a better sense of the implementation details. The end result is great, although I have some minor doubts about the final UX, which is why I think we should move all the UI code to #2465907: Node revision UI reverts multiple languages when only one language should be reverted. It's completely fine to keep posting patches including dependencies over there, as we did here so far, if we want to ensure all tests are still green. However this will make focusing on the low-level stuff here easier.

Aside from that, I still have some doubts about the actual implementation:

I choose ContentTranslation because that's where it's ensured that a ChangedItem is added to an entity. If we want to move the revision pointer to ContentEntity we need to be consequent and require a ChangedItem at this layer as well if the entity is revisionable.

Actually all revisionable entity types need to manually define revision metadata fields to work correctly, so adding a couple of further requirements does not really change the overall situation. Revision and translations cannot work properly if we don't have a changed field and a revision pointer, so we need to make them required, no point in trying to get around that. What we could to loosen these constraints could be falling back to the current core behavior (i.e. reverting all languages) if one of the two fields is not defined. In this scenario, revisionable and translatable entity types will be required to define an additional (translatable!) revision metadata field, e.g. revision_last_affected (instead of content_translation_revision). Name to be defined, but I'd prefix it with revision_ as we do that for all the other ones.

In this scenario, I think we no longer need the ContentTranslationRevisionPointerItem: revision and translation business logic are the domain of the core (Content) Entity API: they both are natively handled by all the Entity API subsystems, thus properly handling revision translations should be part of that. Specifically, properly calculating/storing the revision pointer values would be the storage handler's responsibility. Additionally, moving that logic in the storage layer may make things slightly easier to implement.

Going further with this scenario, we may not even require the changed field: if I'm not mistaken, right now we are using it just track whether an entity (translation) object has changes wrt to its stored version. However it seems that the logic currently implemented in ChangedItem::hasEntityChanges() has nothing specific to the changed item, and could easily live in the (Content) Entity object itself. In this case, the storage handler should have everything it needs to calculate and store the revision pointer value.

What do you think?

yched’s picture

Not too familiar with the contents of the patch, but @plach's #66 sounds appealing. +1 to anything that lets us move behavioral logic out of specialized subclasses of 'timestamp' field type.

Coding behavior / value in the field type is an antipattern we should try to avoid.

plach’s picture

Assigned: mkalkbrenner » catch
Priority: Major » Critical
Issue tags: +D8 upgrade path, +Needs framework manager review

Spoke with @catch about this during the critical issues call. The proposed solution will have an impact on the upgrade path, and I'd really like not to have to provide an upgrade for this. Since the bug is borderline critical, we agreed to make it tentatively critical, so maintainers will have a chance to discuss it in the next triage round and decide whether it actually is.

Assigning to catch because we need his feedback also on the proposed solution (see #65 and following).

catch’s picture

OK so there are two things to consider regarding whether this is critical or not:

1. The inadvertent data-loss between translators when reverting revisions. This is similar to #2484037: Make Views bulk operations entity translation aware. The difference here is that the old revision information isn't completely deleted, but it's not possible without manual editing/comparison to get both translations to the desired state. I don't personally have a strong view on this.

2. If it is critical, then we should try to fix it before we start supporting the upgrade path officially, since the upgrade path required here will be non-trivial and could end up delaying the patch overall and hence the release. I'd really like to avoid that extra work.

So I think we need to decide now that the issue is critical and get it fixed before the upgrade path is officially supported. Or we can decide it's major - and possibly too disruptive to commit to 8.0.0 and defer to 8.1.x. But what we definitely shouldn't do is decide this is critical after supporting the upgrade path, since that's adding extra scope compared to the proposal.

#66 also appeals to me as a solution. - standardizing across revisionable/translatable entity types is fine - and better to have a couple of metadata fields required if that simplifies the handling.

plach’s picture

Assigned: catch » Unassigned
Issue tags: -Needs framework manager review

Thanks @catch, +100 on #69. Personally I think we should do this before we release 8.0.0. and thus we should keep this as critical, but I may be biased :)

mkalkbrenner’s picture

Or we can decide it's major - and possibly too disruptive to commit to 8.0.0 and defer to 8.1.x

To have this issue postponed for drupal 8.1.x scares me a lot. I will repeat my own arguments again. And also some others which are spread across different issues that has been split off from this one.

@mkalkbrenner: https://www.drupal.org/node/2453153#comment-9758203

The current behavior of the drupal 8 core regarding translations and revisions will be a show stopper / blocker for any migration from multilingual drupal 6 or 7 installations having multilingual editorial workflows - like the osce and my organization. That's why I created this issue to address at least one problem miro_dietiker discussed in his blog post.

@webchick: https://www.drupal.org/node/2428795#comment-9842025

Generally there was agreement that the issue noted in #2453153: Node revisions cannot be reverted per translation is legitimate, and needs to be fixed.

@Schnitzel: https://www.drupal.org/node/2453153#comment-9747221

wow, very nice idea and solution to a definitely big problem.

@gnindl for osce.org: https://www.drupal.org/node/2453153#comment-9757787

Having dependent revisions in Drupal 8 would force us to think to upgrade to another CMS. IMHO the demand for such a feature is very strange. I assume that people who have only worked with a single language, e. g. English, can come up with such an idea.

@matsbla: https://www.drupal.org/node/2465907#comment-10013437

I tested patch in:
https://www.drupal.org/node/2453153#comment-9989239

It works great!

mkalkbrenner’s picture

My motivation for this issue are future migrations of various multilingual Drupal 6 and 7 sites to Drupal 8. From this point of view the decision about ContentTranslation vs. ContentEntity doesn't really matter (in our case), because we're always talking about nodes.

In my patches I choose ContentTranslation because it seemed as the easiest way to have the issue fixed for nodes.
If we now decide to fix it for all translatable entities, I'll be fine with that, even if it means additional work. I offer to help with that as long as we agree on having the feature in drupal 8.0.x.

#66 also appeals to me as a solution. - standardizing across revisionable/translatable entity types is fine - and better to have a couple of metadata fields required if that simplifies the handling.

Basically we need one field that stores the revision id to which an edit belongs. (Within my patch for ContentTranslation I named it ContentTranslationRevisionPointerItem.) The translatable ChangedItem has already be solved by #2428795: Translatable entity 'changed' timestamps are not working at all. But it makes sense to declare it as required if we go for the ContentEntity approach.

If we postpone it to drupal 8.1.x I'm really affraid of the multilingual drupal 6 websites (like ours). Without having this issue fixed we can't migrate them to drupal 8. If the support for drupal 6 ends, the only remaining way out will be a migration to drupal 7. That really sounds stupid.

plach’s picture

@mkalkbrenner:

If you are ok with #66 too, we could have this RTBC pretty quickly, since that would mean mostly moving some code around, and then have it ready before the upgrade path is. At that point it doesn't really matter whether this is critical or not, because it would no longer be an upgrade path blocker.

plach’s picture

To be clear: I can make this my current top priority, but we need to agree on a way forward.

mkalkbrenner’s picture

@plach:
As mentioned in #72 I'm fine with your approach in #66!
Even more if you help implementing it to get it RTBC soon :-)

Nevertheless it seems very attractive to me to keep this issue here as critical ;-)

plach’s picture

As mentioned in #72 I'm fine with your approach in #66!

Sorry, somehow it wasn't completely clear to me :)

Even more if you help implementing it to get it RTBC soon :-)

Well, I could certainly work on this, but then we'd need to find someone to review/RTBC my patches. If you have time to work on this, I think it will be faster if you work on #66 and let me review the result as soon as it's ready. If you are not planning to work on this soonish, let me know and I'll try to move it forward during the weekend.

mkalkbrenner’s picture

Well, I could certainly work on this, but then we'd need to find someone to review/RTBC my patches. If you have time to work on this, I think it will be faster if you work on #66 and let me review the result as soon as it's ready. If you are not planning to work on this soonish, let me know and I'll try to move it forward during the weekend.

Maybe I can start today. But it will speed up things if you directly point me to place where i have to declare the required fields or give me an example where corresponding fields are implemented. Just to be sure and to not lose any time ...

plach’s picture

It should be just matter of manually adding a revision_last_affected (or whatever it will be called) base field definition to:

  • BlockContent::baseFieldDefinitions()
  • Node::baseFieldDefinitions()
  • EntityTestMulRev::baseFieldDefinitions()
  • EntityTestMulRevChanged::baseFieldDefinitions()
mkalkbrenner’s picture

OK. But there's a remaining question:
How do we set the values of these fields?
Could we keep my approach based on the field types (ChangedItem and a new one)?
In this case it's really just moving code from ContentTranslation to different places in core.
Or do we have to move that logic from the field items to ContentEntityBase, for example the change detection?

plach’s picture

I think we should move ChangedItem::hasEntityChanges() to a new ContentEntityInterface::hasChanges() method (we can move it to EntityInterface in a separate issue, as that would be BC change). And then we should move the code that's currently implemented by ContentTranslationRevisionPointerItem in ContentEntityStorageBase.

And move the UI changes to #2465907: Node revision UI reverts multiple languages when only one language should be reverted :)

jhedstrom’s picture

Status: Needs review » Needs work

CNW to split the patch.

jhedstrom’s picture

Assigned: Unassigned » jhedstrom

I'll give #80 a try.

mkalkbrenner’s picture

@jhedstrom: I'm already working on it.

jhedstrom’s picture

Assigned: jhedstrom » Unassigned

@mkalkbrenner thanks!

mkalkbrenner’s picture

Assigned: Unassigned » mkalkbrenner

@catch: I assign the issue back to me while I'm working on it, ok?

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new9.31 KB

I created a first patch. It's still work in progress and I expect a lot of tests to fail. Even the revision_last_affected could not work at all because it requires #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks.
Nevertheless I will upload the patch to get first results from the testbot and the patch could be considered as a draft to discuss the technical approach.
BTW all the UI has been removed and will be addressed later by #2465907: Node revision UI reverts multiple languages when only one language should be reverted.

mkalkbrenner’s picture

StatusFileSize
new1.58 KB
new9.44 KB

Already found a bug locally before the testbot has finished ;-)
Remember, even if the tests will pass now, the patch is not fully functional unless it is combined with https://www.drupal.org/node/2478459#comment-10010271

The last submitted patch, 86: 2453153_86.patch, failed testing.

Status: Needs review » Needs work

The last submitted patch, 87: 2453153_87.patch, failed testing.

plach’s picture

  1. +++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php
    @@ -37,4 +37,21 @@ public function getChangedTime();
    +
    +  /**
    +   * Determines if the entity has changes.
    +   *
    +   * @return bool
    +   *   TRUE if the entity has changes, FALSE otherwise.
    +   */
    

    Sorry, probabaly I didn't explain myself clearly. The ::hasChanges() method does not need to be tied to the changed timestamp: any entity can have changes, because it can differ from the stored version. This method should live on ContentEntityInterface, and should be moved up the hierarchy tree into EntityInterface in a followup implementing it also for config entities (which should be easier).

  2. +++ b/core/lib/Drupal/Core/Entity/EntityChangedInterface.php
    @@ -37,4 +37,21 @@ public function getChangedTime();
    +  public function hasChangesAcrossTranslations();
    

    I'd avoid generalizing the ::*AcrossTranslations() pattern, as then we'd needed it everywhere. After all looping on the available translations is quite easy and natural.

  3. +++ b/core/lib/Drupal/Core/Entity/EntityChangedTrait.php
    @@ -28,4 +28,76 @@ public function getChangedTimeAcrossTranslations() {
    +        // The originalEntity will be reset by $this->preSave().
    ...
    +    $language_id = $this->language()->getId();
    +    // @todo How to detect the right field name? Or is the hard-coded value
    +    //   sufficient for a trait?
    +    $changed_field_name = 'changed';
    +    $changed_field = $this->get($changed_field_name);
    +    $translatable = $changed_field->getFieldDefinition()->isTranslatable();
    +    if ($translatable) {
    +      $original = $original->getTranslation($language_id);
    +    }
    

    If we don't invoke the ::hasChanges() method from within a save process (or we do before ChangedItem::preSave() is invoked), the following logic does not need the changed field: if any field has changes, the entity (translation) object has changes.

    #60 is suggesting to calculate which translations have changes for the current revision (and update the revision pointer field) in ContentEntityStorageBase exactly for this reason: if we override EntityStorageBase::save(), compute the revision_last_affected value, and then invoke the parent ::save() method, we should be able to have our values in place before ChangedItem has a chance to do any harm.

  4. +++ b/core/lib/Drupal/Core/Entity/EntityChangedTrait.php
    @@ -28,4 +28,76 @@ public function getChangedTimeAcrossTranslations() {
    +        // The originalEntity will be reset by $this->preSave().
    

    Is this still true?

  5. +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/RevisionPointerItem.php
    @@ -0,0 +1,47 @@
    +class RevisionPointerItem extends IntegerItem {
    

    And thus this can go away, which lets us not depend on #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks and avoid a rewrite.

mkalkbrenner’s picture

The ::hasChanges() method does not need to be tied to the changed timestamp: any entity can have changes, because it can differ from the stored version.

It isn't! The code just contains a shortcut to skip the expensive detection if the changed timestamp is already changed.
Nevertheless I consider the changed timestamp to be a logically required field for revisionable entities.

This method should live on ContentEntityInterface

I choose the existing EntityChangedInterface which is more obvious from a DX perspective. See the explanation below.

any entity can have changes, because it can differ from the stored version. This method should live on ContentEntityInterface, and should be moved up the hierarchy tree into EntityInterface, in a followup implementing it also for config entities (which should be easier).

That's a point that needs further discussion. We're talking about two "completely" different kinds of hasChanges(), even for non-ConfigEntities. (That reminds me of the discussions regarding the ChangedItem.)
The implementation you might have in mind is about an absolute decision whether an entity has any changes compared to the stored one or not.
From a revertable translation revision only perspective you have to exclude various fields from that implementation, for example the non-translatable fields.
Again, we're mixing two use-cases here and might repeat the same error that initially caused #2428795: Translatable entity 'changed' timestamps are not working at all. There we solved it by destinguish both use-cases by providing two methods: getChangedTime() and getChangedTimeAcrossTranslations().

With this information in mind it's obvious to implement the functionality in EntityChangedTrait and to leverage the EntityChangedInterface for the use-case we're targeting here.

The detection of absolute changes instead has to be implemented deep in the entity base classes like you suggested.

BTW we have to think again carefully about naming methods, because hasChanges() has two different meanings.

plach’s picture

StatusFileSize
new26.41 KB
new26.2 KB

I made a few "on-paper" tests to verify this:

The implementation you might have in mind is about an absolute decision whether an entity has any changes compared to the stored one or not.
From a revertable translation revision only perspective you have to exclude various fields from that implementation, for example the non-translatable fields.

It came quite natural to me to flag things the way Miro was suggesting in #13. Discussing with @mkalkbrenner pros and cons of the two approaches.

plach’s picture

StatusFileSize
new70.02 KB

I had a long discussion about this with mkalkbrenner and hchonov, but no final conclusion yet, here's the log.

mkalkbrenner’s picture

I had a long discussion about this with mkalkbrenner and hchonov, but no final conclusion yet

Nevertheless the discussion was very important and we already agreed and some details. And we agreed on which problems need to be solved.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new10.4 KB

A first draft using a flag instead a revision id. Still work in progress ...

Status: Needs review » Needs work

The last submitted patch, 95: 2453153_95.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new10.8 KB

That was the wrong file. This should be the draft.

Status: Needs review » Needs work

The last submitted patch, 97: 2453153_97.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new10.85 KB

Status: Needs review » Needs work

The last submitted patch, 99: 2453153_99.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new10.56 KB

Status: Needs review » Needs work

The last submitted patch, 101: 2453153_101.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new12.04 KB
new19.43 KB

added a test

Status: Needs review » Needs work

The last submitted patch, 103: 2453153_103.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new20.25 KB

First I want to repeat the basic concept @plach, @hchonov and myself agreed on:

Every translation needs to be marked by a flag if it is edited within a revision. Therefor we store a new translatable and revisonable boolean field that's currently named 'revision_last_affected'. The value is TRUE as soon as a translation is edited.
If a new revision is created the flags of all translations are initially reset to FALSE. (see mulrev-revert-flag.pdf created by @plach)

The new field is required because the already existing translatable changed timestamp isn't sufficient to judge to which revision an edit belongs because two revisions could be created within the same second. Nevertheless the translatable changed timestamp is an important information that will later be used to build a sophisticated UI or workflow modules in contrib.

Now I want to explain my patch / my proposed implementation:

I extended the already existing ContentEntityChangedTest to verify all the different states of the new boolean flag. I assume that this test could be kept, even if we decide to modify parts of the architectural approach.

I moved the internal logic of ChangedItem to a public method defined by ContentEntityInterface::hasChanges().
I already added that as Todo in #2428795: Translatable entity 'changed' timestamps are not working at all as requested by @catch. This is also welcome by @plach (and @yched, I guess). ChangedItem itself now leverages that public method.

The logic to set the value of the new flag is nearly identical to the ChangedItem so that ContentEntityInterface::hasChanges() could be reused.

The detail that needs further discussion is the way the value of the new flag is set. @plach's current favorite approach is to set the field value in ContentEntityStorageBase::save() as a kind of hidden feature within the framework (@plach: correct me if I'm wrong.)
My approach is to provide a "simple" field type named ChangedFlagItem that selects its value on preSave() by itself.
BTW the amount of code is roughly the same in both approaches.
Beside the fact that in my opinion the field type is the more elegant and portable solution I see another advantage.
The dedicated field type could potentially be useful when someone creates the UI for reverting single translations or sophisticated workflow UIs.

In general if a field gets its value somehow magically, as a developer I would expect to find that magic implemented using the field API. From the information about the field type within an entity I'm directly pointed to ChangedFlagItem::preSave() and see how the value is detected or modified.

BTW the naming of the fields and the methods is just a draft.

mkalkbrenner’s picture

FYI: I uploaded the UI code that has been removed from the patches here as a separate patch for #2465907: Node revision UI reverts multiple languages when only one language should be reverted.

mkalkbrenner’s picture

While writing the comment #93 for #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks I thought about a different argument to use a new field type for the flag instead of setting it from within the storage classes:
I think it's a good pattern for contrib developers whenever they want to achieve something similar. They should use the Field API and not hack the storage.

catch’s picture

Discussed with plach, and did a very superficial review of the patch.

I think I'd keep this in the storage layer. This is specifically tracking whether something we've stored has changed internally. Revisions and translations are central to the entity system. Contrib doing special handling has access to field types and hooks, so baking this in doesn't stop them doing what they need to for other kinds of changed tracking.

+++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
@@ -1012,4 +1013,60 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
+  /**
+   * {@inheritdoc}
+   */
+  public function hasChanges() {
+    static $original = NULL;
+
+    if ($this->isNew()) {
+      return TRUE;
+    }
+
+    if ($this->original) {

This should also check untranslatable fields, otherwise it will report that things haven't changed when they have.

mkalkbrenner’s picture

@catch

This should also check untranslatable fields, otherwise it will report that things haven't changed when they have.

Please have a look at the irc log at #93 and especially my explanations in #91. We must not check untranslated fields!
We can discuss the name of the function but if it checks for untranslated fields it's unusable to solve our issue here.

Regarding the name of the function I would point out that we already had that same discussion with @webchick @berdir @yched and others in #2428795: Translatable entity 'changed' timestamps are not working at all.

hasChanges() follows the decision that has been taken there that all getters access the translation values.

But it would be OK for me to name it hasTranslationChanges().

I think I'd keep this in the storage layer.

As I already mentioned, my patch could be transformed to that approach very easily without the need to modify the tests or the current patch for #2465907: Node revision UI reverts multiple languages when only one language should be reverted.

Nevertheless I would welcome to hear more opinions from others.

catch’s picture

We can discuss the name of the function but if it checks for untranslated fields it's unusable to solve our issue here.

I don't think we should have a public method that only checks translatable fields.

All we need is when saving a translation revision, to see a boolean whether it changed or not - that's something we should be able to do in a protected method somewhere without anything in the public API.

Nevertheless the translatable changed timestamp is an important information that will later be used to build a sophisticated UI or workflow modules in contrib.

That's fine but I think anything to do with this should be split out to another issue, it's very confusing trying to determine what's dealing with setting the translation changed boolean vs. the timestamp and which should be used where.

mkalkbrenner’s picture

I don't think we should have a public method that only checks translatable fields ...
...that's something we should be able to do in a protected method somewhere without anything in the public API.

Basically you're right. But that's exactly the same discussion we already had :-(

The already solved translatable changed timestamp currently carries the required logic to detect a change on a translation. The same logic is required for the flag. If we don't won't to copy the code, we have to provide it as public method because it's needed by a field and the storage (as @catch and @plach proposed).

That's fine but I think anything to do with this should be split out to another issue, it's very confusing trying to determine what's dealing with setting the translation changed boolean vs. the timestamp and which should be used where.

We already did that! The separate issue for the changed timestamp was #2428795: Translatable entity 'changed' timestamps are not working at all. Here we're only discussing about the boolean flag, nothing else. But as I already mentioned the logic that is required to set the flag's value is mostly the same that we already have within the ChangedItem. And I want to leverage that code.

plach’s picture

Issue tags: +Entity Field API
plach’s picture

Issue tags: +entity storage

I've just read again the logs of all our previous discussions and I think I've changed my mind on a point that might allow us to reach a consensus on the way forward. I wanted to speak with @catch about it but I wasn't able to, so we still need his approval.

What is bothering me about the current approach is introducing a public API method named ::hasChanges() inspecting only translatable fields. Additionally I was not happy with ChangedItem automatically updating its value only when translatable fields are changed.

After inspecting some core use cases, I realized that there is no way to hard-code a single behavior that would work in all cases. Given that, I think now I'm fine with the current ChangedItem behavior, since it is possible to manually set a different value for it depending on the use case. What would be horribly wrong would be hard-coding the current behavior without the possibility to override it.

That said, I think this could be a way forward:

  • Define a new revision_translation_affected boolean translatable field.
  • Move the logic in ChangedItem::preSave() to a new ContentEntityBase::hasTranslationChanges() method (and define ContentEntityInterface::hasTranslationChanges()).
  • Populate $entity->revision_translation_affected in ContentEntityStorageBase::save() and invoke EntityStorageBase::save() subsequently. To make sure the flag value can be manually overridden we should populate it only if its current value is NULL, which should be the initial value for all flag translations after a new revision is created.
  • To avoid performing ::hasTranslationChanges() twice, we could statically cache its value and invalidate it in ContentEntityBase::onChange().
catch’s picture

I think #113 works for me, but I'm not sure why hasTranslationChanged() in the last bullet would be called twice? Is this just in case some other code calls it?

mkalkbrenner’s picture

I'm not sure why hasTranslationChanged() in the last bullet would be called twice? Is this just in case some other code calls it?

If the changed timestamp is not explicitly set, ChangedItem::preSave() will call ContentEntityInterface::hasTranslationChanges() to judge if its value needs to be set to REQUEST_TIME automatically.

According to #113 $entity->revision_translation_affected is already set to the return value of ContentEntityInterface::hasTranslationChanges() a little bit earlier during ContentEntityStorageBase::save(). Therefor it makes sense to cache the result of ContentEntityInterface::hasTranslationChanges() during save and to reset the cache after save.

I think that's the explanation if we simply follow the bullets in #113 ;-)

If we want to avoid the caching, there's an alternative implementation that has the same positive effect from a performance perspective:
If an entity is revisionable and $entity->revision_translation_affected is a required field on revisionable entities, this field is set to the correct value in ContentEntityStorageBase::save() before preSave() is called on the other fields.
With that knowledge we can simply get the value of $entity->revision_translation_affected within ChangedItem::preSave() or call ContentEntityInterface::hasTranslationChanges() if the entity is not revisionable.

Both approaches will have the same impact on performance. Thoughts?

plach’s picture

I think that's the explanation if we simply follow the bullets in #113 ;-)

Yep, that's what I had in mind.

Both approaches will have the same impact on performance. Thoughts?

The idea behind caching/invalidating on ::onChange() is not assuming a workflow: since ContentEntityInterface::hasTranslationChanges() could be invoked in any context, it should be re-calculated as soon an entity field value changes.

mkalkbrenner’s picture

The idea behind caching/invalidating on ::onChange() is not assuming a workflow: since ContentEntityInterface::hasTranslationChanges() could be invoked in any context, it should be re-calculated as soon an entity field value changes.

I agree, let's do it. @catch?

plach’s picture

Just had this chat with @catch in IRC:

catch plach : so with the translation check static caching, could we not put an additional, temporary property on the entity?
catch And then check if that is set?
plach catch : wouldn't that be exactly the static caching I was proposing?
catch plach : if that's what you meant by the static cache, then yes :P
plach catch : sorry; I should have been more explicit :) I meant we'd have a $translationChanged protected property on the entity object that would store the check result and the would be reset to NULL in ::onChange()
catch plach : OK so yeah sounds fine :)
plach catch : however, I think I found a problem with my suggestion
WimLeers|afk is now known as WimLeers
plach catch : if any code running in ::preSave() methods or presave hooks implementation changes the entity we wouldn't record the translation as affected, becuase the related field has already been popoulated
catch plach : good point
plach catch : the only reason I was suggesting to do that *before* pre-save was to that the ChangedItem would always be changed
plach either manually or automatically
plach so every translation would always be marked as affected
plach catch : however if ChangedItem is not updated if there are no actual changes, then it should be safe to populate the field *after* the pre-save phase
plach catch : does it make sense?
catch plach : so presave either sets it to true, or something could set it to FALSE explicitly.
catch plach : and after presave, if it's null, set it to TRUE?
plach catch : the flag field? yes
plach (revision_translation_affected)
catch plach : bit distracted with crying baby but sounds OK
plach ok :)

@mkalkbranner:

So, with the corrections above to #113, I'd say we have a plan ;)

mkalkbrenner’s picture

StatusFileSize
new22.2 KB

Yesterday @catch, @plach and myself had a very long discussion on IRC. We agreed that the flag field must not detect its value automatically if it is already set manually, for example during a migration based on the migrate module.
We discussed several approaches to achieve that but didn't find a solution that works for all use-cases.

But probably I found a solution now. Therefor I created a new patch based on #105 that solves that particular problem and adds a test for it.

In order to keep the interdiff as small as possible to be able to discuss my approach I didn't yet rename the functions and properties to the new names we agreed on.

@plach: Don't blame me for still using a new field type to solve the problem ;-) Any idea how to achieve something similar using a standard BooleanItem and the logic implemented in the storage layer?

mkalkbrenner’s picture

StatusFileSize
new3.47 KB

Didn't upload the interdiff ...

Status: Needs review » Needs work

The last submitted patch, 119: 2453153_119.patch, failed testing.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new3.38 KB
new22.22 KB

I run into a cache issue with the previous patch. The loadingCompleted flag needs to be set on entities fetched from the cache as well.
Unfortunately there's no base class for the various cache implementations. Therefor the flag needs to be cached as well, which isn't a problem logically.
But the cache is set within the concrete storage implementations and not in the base class (I consider that a bug) and the only available callback like entry points before the cache is set are two hooks - hook_entity_storage_load and hook_ENTITY_TYPE_sorage_load. There are no corresponding static callbacks on content entities and EntityInterface::postLoad() is too late :-(
Therefor I had to set the flag in the concrete storage implementation SqlContentEntityStorage::getFromStorage() where the hooks mentioned above are fired. These hooks and potentially my flag should be moved in a base class similar to the issue addressed by #2478459: FieldItemInterface methods are only invoked for SQL storage and are inconsistent with hooks.
Another option will be to implement one of the hooks in system.module, but that looks like bad design, too.

Nevertheless, let's discuss my approach for this issue here first ...

jhedstrom’s picture

+++ b/core/modules/node/src/Entity/Node.php
@@ -494,6 +494,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields['revision_last_affected'] = BaseFieldDefinition::create('changed_flag')
+      ->setLabel(t('Revision last affected'))
+      ->setDescription(t('Indicates if the last edit belongs to current revison.'))
+      ->setReadOnly(TRUE)
+      ->setRevisionable(TRUE)
+      ->setTranslatable(TRUE);
+

Given #2507899: [policy, no patch] Require hook_update_N() for Drupal 8 core patches beginning June 29 do these need update hooks, or is the plan to get this in prior to June 29?

mkalkbrenner’s picture

@jhedstrom: This issue here is explicitly mentioned at #2507899: [policy, no patch] Require hook_update_N() for Drupal 8 core patches beginning June 29. Nevertheless I'm available to probably finish this issue before that date. But currently the declared reviewers seem to be very busy.

mkalkbrenner’s picture

StatusFileSize
new7.59 KB
new21.7 KB

While still waiting for feedback I renamed the functions and fields like discussed and moved ContentEntityInterface::hasChanges() to TranslatableInterface::hasTranslationChanges().

effulgentsia’s picture

do these need update hooks

Possibly not, even if this lands after June 29th, and even if it's not granted special exemption.

Because entity schema changes are run by update.php automatically. The code for that is update_entity_definitions(), and DbUpdateController::triggerBatch() makes it run prior to hook_update_N() functions, which is good, because it means you can also have a hook_update_N() that loads and saves entities and it will do so with the correct database schema already in place.

However:

  • This patch adds a ChangedFlagItem field type, but update.php does not appear to clear the discovery cache prior to checking what updates are available. That's not this patch's fault. If we don't already have an issue for that, we should add one. Until that, in order to test an install of HEAD followed by applying this patch followed by running update.php, you need to clear cache between applying the patch and running update.php.
  • If you already have existing nodes or custom blocks, then update.php will initialize the new revision_translation_affected column with a value of NULL for those existing entities. I did not test whether the patch then works correctly with that as the value. It would either need to be able to handle that, or else the field definition needs to be given some other default/initial value, or else we'd need a hook_update_N() that loads each entity, gives the field a value that is correct for that entity, and saves that entity. That last option, however, would have serious scalability problems for a site that has millions of entities, so if it needs to be a calculated value, perhaps better would be to calculate as part of saving a revision rather than needing to do it all during the update?

Given the above, I also think it wouldn't be catastrophic if this didn't make it into 8.0.0 and ended up getting fixed in 8.1.0. So, I question whether it should remain a Critical. I'm happy for it to make it into 8.0.0 if it does, but I don't think it's worth holding up 8.0.0's release on it.

plach’s picture

Aside from the stuff below, I think this is as good as it gets with the field type approach. I'd still like to explore the no-field-type approach, though :)
I will have a try tomorrow.

Also, for this to be complete we should make node reverting actually work. Without introducing any UI changes I think it should be easy to revert all the affected translations. This would fix the worst part of this issue, and if we weren't able to complete all the other stuff in the meta before 8.0.0 is released, we would still have a functional UI, although less than optimal. However improvement would definitely be possible in 8.1.0.

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -1012,4 +1014,45 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
    +    static $original = NULL;
    

    This looks very fragile: I'm wondering whether we can avoid this by checking for the existence of translations, as pointed out below.

  2. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -1012,4 +1014,45 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
    +    $translatable = $this->isTranslatable();
    

    I think what we really want to know is whether the entity is translated, because all this behavior is useless on translatable but not translated entities. So what about:

    $translated = count($this->translations) > 1;
    
  3. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -1012,4 +1014,45 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
    +    $translation = ($translatable) ? $original->getTranslation($this->language()->getId()) : $original;
    

    If I get the comment above right, we modify $this->original when we try to instantiate a translation that was not originally stored. Would the following work?

      public function hasTranslationChanges() {
        /** @var static $original */
        $original = &$this->original;
    
        if ($this->isNew()) {
          return TRUE;
        }
    
        if (!$original) {
          $id = ($this->getOriginalId() !== NULL) ? $this->getOriginalId() : $this->id();
          $original = $this->entityManager()->getStorage($this->getEntityTypeId())->loadUnchanged($id);
        }
    
        $translated = count($this->translations) > 1;
        if ($translated && !$original->hasTranslation($this->activeLangcode)) {
          return TRUE;
        }
    
        /** @var static $translation */
        $translation = $translated ? $original->getTranslation($this->activeLangcode) : $original;
        foreach ($this->getFieldDefinitions() as $field_name => $definition) {
          if (!$definition->isComputed() && (!$translated || $definition->isTranslatable())) {
            $items = $this->get($field_name)->filterEmptyItems();
            $original_items = $translation->get($field_name)->filterEmptyItems();
            if (!$items->equals($original_items)) {
              return TRUE;
            }
          }
        }
    
        return FALSE;
      }
    
  4. +++ b/core/lib/Drupal/Core/Entity/ContentEntityBase.php
    @@ -1012,4 +1014,45 @@ public static function bundleFieldDefinitions(EntityTypeInterface $entity_type,
    +    foreach ($this->getFieldDefinitions() as $other_field_name => $other_field_definition) {
    

    Can we rename these to $field_name and $definition? See above.

plach’s picture

@eff:

Given the above, I also think it wouldn't be catastrophic if this didn't make it into 8.0.0 and ended up getting fixed in 8.1.0. So, I question whether it should remain a Critical. I'm happy for it to make it into 8.0.0 if it does, but I don't think it's worth holding up 8.0.0's release on it.

I'm not sure I agree with this conclusion: supporting an upgrade for this would be way worse in 8.1.0, because every 8.x website out there would be affected by the upgrade. Instead if we do this before 8.0.0 only a (way) smaller set of sites would be affected.

Ideally I would like this to be fixed before supporting the upgrade path: you are right the schema would be updated automatically, but updating data would be tricky because we cannot rely on the Entity API in update functions and thus we could provide an update function only for the default SQL storage.

It would be nice if could provide an self-healing behavior, so that field values would be fixed by just saving entities, or at least we could provide some sort of fallback behavior if data for affected translations is missing, like reverting all translations as we currently do in HEAD.

mkalkbrenner’s picture

StatusFileSize
new4.36 KB
new22.67 KB

Status: Needs review » Needs work

The last submitted patch, 129: 2453153_129.patch, failed testing.

mkalkbrenner’s picture

Component: node system » entity system
Status: Needs work » Needs review
StatusFileSize
new1.61 KB
new22.75 KB
new4.31 KB

I applied the appropriate changes that @plach suggested in #127.
In addition I added the same improvement to ChangedItem that went into ChangedFlagItem to detect manual sets of the field value via the API. This should 100% solve #2329253: Allow ChangedItem to skip updating the entity's "changed" timestamp when synchronizing without the need for any additional patch.

@plach: The intermediate step in #129 demonstrates why 'clone' is required. If this seems like a bug this needs to be handled as a separate issue because it is not caused here.

plach’s picture

I'm fine with clone, I was scared by the static. What about the node UI?

Also, for this to be complete we should make node reverting actually work. Without introducing any UI changes I think it should be easy to revert all the affected translations. This would fix the worst part of this issue, and if we weren't able to complete all the other stuff in the meta before 8.0.0 is released, we would still have a functional UI, although less than optimal. However UI improvements would definitely be possible in 8.1.0.

mkalkbrenner’s picture

@plach: OK, got it :-) I'm in a hurry because of the 29th of June ;-)

@plach, @effulgentsia:
Regarding the UI. We have a working patch in #2465907: Node revision UI reverts multiple languages when only one language should be reverted that fixes the issue for nodes. It only requires a minor adjustment to the patch in #131. From my point of view a UI for nodes is sufficient at the moment because that is what is required to bring back the feature that existed in Drupal 7 core.
Due to the fact that this pure UI enhancement in #2465907: Node revision UI reverts multiple languages when only one language should be reverted has no effect on the schema, we're not in trouble with the upgrade path after the 29th, right?

catch’s picture

It would be nice if could provide an self-healing behavior, so that field values would be fixed by just saving entities, or at least we could provide some sort of fallback behavior if data for affected translations is missing, like reverting all translations as we currently do in HEAD.

So I think this would probably be fine as an 'upgrade path' for existing data - since reverting is not that common an operation, only goes back one or two revisions usually, and the data is there, just not very accessible.

If we do that, then the upgrade path here in general looks much less scary, and I'd agree with effulgentsia that this could go back to major (since we don't actually lose data here, just bury it a bit). I'd also be much happier committing it any time up to RC in that case. However, would really like to be sure about that before downgrading, since having to upgrade this to critical later and write the migration path for all the old data after all would not be nice.

mkalkbrenner’s picture

@effulgentsia:

Given the above, I also think it wouldn't be catastrophic if this didn't make it into 8.0.0 and ended up getting fixed in 8.1.0. So, I question whether it should remain a Critical. I'm happy for it to make it into 8.0.0 if it does, but I don't think it's worth holding up 8.0.0's release on it.

I totally disagree! Even if I repeat myself as you can see in the earlier comments (maybe spread over the multiple issues this one has been split into), without having this critical fixed it's impossible to migrate existing multilingual Drupal 6 an 7 sites that have editorial workflows using revisions to Drupal 8.

@effulgentsia:

If you already have existing nodes or custom blocks, then update.php will initialize the new revision_translation_affected column with a value of NULL for those existing entities. I did not test whether the patch then works correctly with that as the value. It would either need to be able to handle that, or else the field definition needs to be given some other default/initial value, or else we'd need a hook_update_N() that loads each entity, gives the field a value that is correct for that entity, and saves that entity. That last option, however, would have serious scalability problems for a site that has millions of entities, so if it needs to be a calculated value, perhaps better would be to calculate as part of saving a revision rather than needing to do it all during the update?

The nature of the new flag is that there is no reasonable default value that could be applied to existing entities. And it could not be reliable calculated afterwards for existing entities. Therefor it's important to have the flag in core before we have to deal with an upgrade path.

So what's the problem with this issue?

@plach, @catch and myself had long disussions about this issue.

We have consesus that we need to add a additional field of a boolean type named 'revision_translation_affected' to the schema.

We have consesus how the value of 'revision_translation_affected' is detected automatically and that this detection has to be turned off if it is set manually, for example by using the migrate module.

We have consesus that the required detection logic is the same as the existing one within ChangedItem::preSave() and therefor is re-usable.

We have consesus that the re-usable detection logic should be provided by a public method named TranslatableInterface::hasTranslationChanges() that has to be implemented by ContentEntityBase.

The only remaining points of discussion are the most performant implementation of ContentEntityBase::hasTranslationChanges() and which boolean field type to use for the new 'revision_translation_affected' base field.

Performance improvements to ContentEntityBase::hasTranslationChanges() could be easily moved to a non-critical follow-up issue that doesn't affect the upgrade path.

@plach: Wether we set the value of 'revision_translation_affected' within the preSave() of the field type or from the storage, the introduction of ChangedFlagItem makes sense, because it's lightwight compared to BooleanItem which is prepared for the UI. Adjustments to it could therefor be addressed by a follow-up issue as well.

With this argumentation I recommend that we commit the patch now as it is. It's functional and has test coverage and the potentially remaining tasks are non-critical and don't affect the upgrade path. This way we avoid all the potential trouble that might araise with the upgrade path.

mkalkbrenner’s picture

We are probably affected by a different issue here, but only when we use the node form, not via the API. We're still investigating ...

plach’s picture

This patch builds on top of #131 but does not rely on new field types. As agreed during our last discussion, it implements a minimal fix for the node revision UI to cover the use case described in the OP and provides test coverage for that.

Notes:

  • I removed the code implementing manual setting detection for field item values: I think it's a valid approach but it could use a dedicated issue to allow the key people (@yched, @fago, @berdir) to review it and discuss the implementation details. We could repurpose #2329253: Allow ChangedItem to skip updating the entity's "changed" timestamp when synchronizing for that. Meanwhile I special-cased a couple of fields to make things work, I think this is fine, since fixing that will be just an internal change.
  • I was able to remove the various clones: not sure whether that indicates a lack of test coverage of whether I was able to find a proper fix.
  • I changed one assertion in the existing test coverage:
        $this->assertTrue(
          $this->getRevisionTranslationAffectedFlag($entity),
          'Changed flag of original language is set also for new revision without changes.'
        );
    

    The reason is that to improve performance for untranslated entities we now always set translations as affected. I think this is an acceptable change, but obviously totally open to discuss that.

  • This also makes sure the revision_translation_affected field cannot be configured in the Content Translation admin UI.
plach’s picture

Some comment clean-ups

plach’s picture

The last submitted patch, 137: et-node_revisions-2453153-137.test.patch, failed testing.

mkalkbrenner’s picture

Status: Needs review » Needs work

The last submitted patch, 141: et-node_revisions-more_tests-2453153-141.patch, failed testing.

mkalkbrenner’s picture

I removed the code implementing manual setting detection for field item values: I think it's a valid approach but it could use a dedicated issue to allow the key people (@yched, @fago, @berdir) to review it and discuss the implementation details.

@plach, @catch and myself already agreed that it is necessary to allow manual setting of these fields, at least for migrations. This use-case has been described previously in #2329253: Allow ChangedItem to skip updating the entity's "changed" timestamp when synchronizing. With my field type approach in #131 that has been solved without API changes. @plach: does your approach bring back the requirement for API changes to solve #2329253: Allow ChangedItem to skip updating the entity's "changed" timestamp when synchronizing, or do you already have a nice solution in mind? We need it for both. The flag and the timestamp.

I changed one assertion in the existing test coverage ... The reason is that to improve performance for untranslated entities we now always set translations as affected. I think this is an acceptable change, but obviously totally open to discuss that.

@plach: I get the your idea regarding performance and the approach makes sense from a technical point of view. But if I think of the webmaster, the user that has to deal with the UI later, I suggest to skip that shortcut. As soon as translations are added to the entity it looks confusing in the UI (we still have to develop) to get multiple changes listed, that have no changes. Even if I think of contrib modules like diff. They should leverage the new flag. But in this case the diff will be empty.

This also makes sure the revision_translation_affected field cannot be configured in the Content Translation admin UI.

@plach: great! I missed that point.

@plach: 3 days ago I mentioned in IRC that we have a problem if we're not at the API level but create translations using the Node UI. @hchonov and myself debugged a lot to finally identify #2513094: ContentEntityBase::getTranslatedField and ContentEntityBase::__clone break field reference to parent entity. As you can see there we already extended ContentEntityChangedTest to cover that. Therefor I decided to add this test to your patch in #138. But I expect it to fail at the moment.
In addition I changed the assertions according to my comment above regarding the performance improvement vs. usability discussion.
During my local tests it turned out that your patch in #138 in opposite to my patch in #131 seems to have a problem with translation removal on the API level. That's why I decided to upload it as #141 directly without discussing it with you upfront.

mkalkbrenner’s picture

Status: Needs work » Needs review
StatusFileSize
new2.13 KB
new39.49 KB

I fixed the exception thrown when removing a translation and creating a new revision within one step.

And I removed the minimal performance improvement in populateAffectedRevisionTranslations() for usability reasons as mentioned above.

mkalkbrenner’s picture

Reviewing my own patch of #144 ;-)

+++ b/core/modules/system/src/Tests/Entity/ContentEntityChangedTest.php
@@ -358,6 +411,135 @@ public function testRevisionChanged() {
+
+    // The assertion fails unless https://www.drupal.org/node/2513094 is
+    // committed.
+    $this->assertFalse(
+      $this->getRevisionTranslationAffectedFlag($entity),
+      'Changed flag of original language is reset by adding a new translation and a new revision.'
+    );

The comment is just valid for my field type approach in #131. Nevertheless #2513094: ContentEntityBase::getTranslatedField and ContentEntityBase::__clone break field reference to parent entity is an issue that has to be fixed.

plach’s picture

Interdiffs look good to me, thanks :)

@plach: does your approach bring back the requirement for API changes to solve #2329253: Allow the ChangedItem to skip updating, or do you already have a nice solution in mind? We need it for both. The flag and the timestamp.

Well, if the final solution is something along the lines of the removed code, it should be a completely internal change. Maybe it would imply an API addition, but that should be fine.

I'm now happy with the patch, but can no longer RTBC it. I pinged @catch, hopefully he can review it.

catch’s picture

Looks great to me.

gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

Let's get this in then :)

plach’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs change record

never too early to write a change record

;)

plach’s picture

Status: Needs work » Reviewed & tested by the community
Issue tags: -Needs change record

Markus just created it, back to RTBC!

https://www.drupal.org/node/2513894

mkalkbrenner’s picture

Issue summary: View changes
plach’s picture

Displayed again the failing test-only patch that demonstrates that the new test coverage captures the reported bug.

gábor hojtsy’s picture

Assigned: mkalkbrenner » Unassigned

@mkalkbrenner will certainly not be able to commit it.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

A couple of minor nits.

  1. +++ b/core/lib/Drupal/Core/Entity/ContentEntityInterface.php
    @@ -26,4 +26,33 @@
    +  /**
    +   * Marks the current revision translation as affected.
    +   *
    +   * @param bool $affected
    +   *   The flag value.
    +   */
    +  public function setRevisionTranslationAffected($affected);
    

    We pass a null in here - also lets return $this to make the interface fluent.

  2. +++ b/core/modules/content_translation/content_translation.admin.inc
    @@ -138,6 +138,28 @@ function _content_translation_form_language_content_settings_form_alter(array &$
    +// Allow to configure only fields supporting multilingual storage. We skip our
    +// own fields as they are always translatable. Additionally we skip a set of
    +// well-known fields implementing entity system business logic.
    +return
    +  $definition->isTranslatable() &&
    +  $definition->getProvider() != 'content_translation' &&
    +  !in_array($definition->getName(), [$entity_type->getKey('langcode'), $entity_type->getKey('default_langcode'), 'revision_translation_affected']);
    

    Needs to be indented 2 spaces.

plach’s picture

Status: Needs work » Needs review
StatusFileSize
new3.86 KB
new39.67 KB

Addressed #154, thanks.

gábor hojtsy’s picture

Status: Needs review » Reviewed & tested by the community

Updates look good. I first understood the phpdoc was not updated but its on the interface lower down in the interdiff.

catch’s picture

Status: Reviewed & tested by the community » Fixed

Getting this in sooner rather than later so that it's in before the next beta.

Committed/pushed to 8.0.x, thanks!

  • catch committed 131d577 on 8.0.x
    Issue #2453153 by mkalkbrenner, plach: Node revisions cannot be reverted...
plach’s picture

Yay, awesome work @mkalkbrenner!

Status: Fixed » Closed (fixed)

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

gábor hojtsy’s picture

Issue tags: -sprint

Thanks all, especially @mkalkbrenner and @plach!

joseph.olstad’s picture

We have a side by side language1 / language2 interface, our client is used to pressing 'save' once for all translations and would like to revert to a revision for all translations.

For the revisions form I'm thinking of adding a form alter and a checkbox option for 'All Translations at once'

has anyone else already thought of this?