Problem/Motivation

SqlContentEntityStorage::saveToSharedTables() always iterates over all translations of entity on save. In other words, storing an ContentEntity always stores all translations of it. Saving a single translation only is impossible.

But EntityStorageBase::save() only calls preSave() and postSave() on the translation on which you called save().
(Translations are cloned entity objects cached within the entity.)

This might cause any kind of problems and leads into a very confusing API.

Even in core you find a problematic implementation in Node::preSave():

public function preSave(EntityStorageInterface $storage) {
  ...
  // If no owner has been set explicitly, make the current user the owner.
  if (!$this->getOwner()) {
    $this->setOwnerId(\Drupal::currentUser()->id());
  }
  ...
}

The Node::preSave() method should ensure a valid user on each translation of the node. But it's actually only executed on the translation on which you call save().

The failing test case uploaded in #20 demonstrates the problem.

Proposed resolution

One solution might be to explicitly call preSave() and postSave() on every translation of a content entity on save() like it happens in the Field API for translatable fields.
But this solution might cause different kind of trouble because there are existing implementations in core that don't expect to be called multiple times. And even if this won't break anything, it might be a performance hit.

Therefore @catch and @mkalkbrenner discussed a solution to extend the documentation of EntityInterface:preSave() and EntityInterface:postSave() to point out the different behavior compared to the Field API. Node::preSave() must then iterate over all translations according to the documentation.

Remaining tasks

Review the existing patch.

User interface changes

none

API changes

none

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

mkalkbrenner’s picture

Status: Active » Needs review
FileSize
1.44 KB

This patch missed the required "use" statement and therefor didn't change anything for real ;-)
The patch and the interdiff in #7 include it.
And the test result in #7 is green as well.

mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue tags: +D8MI
mkalkbrenner’s picture

This patch simple adds the NodeSaveTest::testPreSaveSetsOwner() to demonstrate the problem.

The Node::preSave() method should ensure a valid user on each translation of the node. But it's actually only executed on the translation on which you call save():

public function preSave(EntityStorageInterface $storage) {
  ...
  // If no owner has been set explicitly, make the current user the owner.
  if (!$this->getOwner()) {
    $this->setOwnerId(\Drupal::currentUser()->id());
  }
  ...
}

The test leads into a fatal error because the owner is not a valid object in all translations.

Status: Needs review » Needs work
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Status: Needs work » Needs review
FileSize
1.52 KB
4.53 KB

Now see how NodeSaveTest::testPreSaveSetsOwner() acts including the patch from #1

mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Issue tags: +sprint
Berdir’s picture

Not convinced about this.

Why would we treat those methods different than the entity hooks?

mkalkbrenner’s picture

@Berdir
Basically I want to point out a problem that might cause strange problems in the future, especially in contrib.
I run into this issue when using the change tracking proposed by @alexpott in #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work. This doesn't work for translated entities:

public function postSave(EntityStorageInterface $storage, $update = TRUE) {
  parent::postSave($storage, $update);
  $this->resetChangedFieldList();
}

I'll try to explain why.
The patch by @alexpott introduces a new property for entities:

/**
  * The list of field names of any fields that have changed.
  *
  * @var string[]
  */
protected $changedFields = array();

If on purpose or not, this property is not one of the "synchronized" ones across the cloned translations in ContentEntityBase::initializeTranslation().
Therefor the tracking of changes in onChange() and the accessor getChangedFields() work separately on each translation (which is great BTW).

But as a consequence the resetChangedFieldList() method needs to called individually per translation!

If we keep the not obvious nature of methods like preSave() in combination with cloned translated entity objects we have two ways to fix #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work:
1. synchronize the changedFields property across translations in initializeTranslation()
or
2. loop over all translations in postSave()

Nevertheless, many contrib developers might step into that trap when they add properties to custom content entities. Especially if they don't think about translation up front.

yched’s picture

That feels a bit related to the weird fact that we currently run hook_entity_create() on new translations of an existing entity. Can't find the issue atm.

Again, we have chosen an intra-entity translation model, yet we have a temptation to get back to treating each translation as a standalone piece. This kind of clashes :-/

plach’s picture

My 2 cents:

The failing test provided in #4 doesn't make a lot sense to me: I realize it was an attempt to demonstrate the issue pointed out in the OP, which I agree is real, but IMO that's the wrong way. It's using an internal value to trigger a behavior that can be known only by inspecting the actual implementation: this is not reliable, since that implementation may change, nor sensible, since if you wish to set the current user you can just specify the current user. If I were a core committer I would never allow such a test to be added to our test suite, since it would provide a bad example to developers.

Again, we have chosen an intra-entity translation model, yet we have a temptation to get back to treating each translation as a standalone piece. This kind of clashes :-/

I always thought to the intra vs extra entity translation model as an implementation detail, the public API is designed to allow developers to think of entity (translation) objects like standalone entities, so I think the DX concerns @mkalkbrenner is pointing out are real (and welcome).

Not convinced about this.
Why would we treat those methods different than the entity hooks?

Well, actually we have dedicated hooks when translations are inserted and deleted, so IMO it would not be so weird if we invoked hook_entity_update on every updated translation object, this would let me write code without worrying about the very translation concept, which would be consistent with the general Entity Translation API approach. The entity storage is already detecting which translations have changes, calling pre/post save methods and the presave/update hooks on each one of those would make sense to me.

plach’s picture

Issue tags: +language-content
mkalkbrenner’s picture

@plach: Just to clarify, even if I'm already convinced that you understood my motivation for the patch :-)

The failing test provided in #4 doesn't make a lot sense to me ... this is not reliable, since that implementation may change, nor sensible, since if you wish to set the current user you can just specify the current user. If I were a core committer I would never allow such a test to be added to our test suite, since it would provide a bad example to developers.

I don't want to get the test included in the patch committed to core! I just looked for something in core to demonstrate the issue I see regarding DX and the potential bugs that might be caused by it.

From my point of view the next step should be to discuss how to solve the issue for real. I see these options:

1. Create a patch that at least documents the nature of these kind of callbacks in combination with entity translation. Additionally implementations of postSave() like in #2456257: [PP-1] Normalizers must set $entity->_restSubmittedFields for entity POSTing/PATCHing to work need to be adjusted to iterate over translations. I don't think that we could provide a test that ensures that up front.
Additionally we should create an API to "register" properties for synchronization across translations or point out in documentation that initializeTranslation() has to overwritten to synchronize an additional property.

2. Extend the patch in #1 to call such callbacks on all translations. In this case we need to modify the existing implementations of preSave() and postSave() in core that expect to be called just once per entity. (In fact that are most of them!) They need to trigger a state by themselves that they only act once per $entity->save() or do a switch statement on the translation language if the multiple execution causes a problem or slows down the performance too much.

Both approaches have their own ugly drawbacks. But we have to ensure a better DX here to avoid bugs, especially in contrib modules. Does anyone have another / better idea to solve this issue?

mkalkbrenner’s picture

Any suggestions how to proceed here?

mkalkbrenner’s picture

Priority: Normal » Major
Issue tags: -sprint
FileSize
2.17 KB

I created a more readable failing test.

Status: Needs review » Needs work
mkalkbrenner’s picture

Status: Needs work » Needs review
FileSize
2.64 KB
5.01 KB

Here's a proposed solution to this problem as discussed with @catch on IRC:

I extended the documentation of EntityInterface:preSave() and EntityInterface:postSave() and modified the implementation of Node::preSave() accordingly to iterate over all translations.

mkalkbrenner’s picture

mkalkbrenner’s picture

Issue summary: View changes
plach’s picture

The patch looks ok, if this is the route we want to go, however:

+++ b/core/lib/Drupal/Core/Entity/EntityInterface.php
@@ -239,7 +239,13 @@ public function delete();
+   * over all translations if needed. This behavior is different from its
+   * counterpart in the Field API:
+   * \Drupal\Core\Field\FieldItemListInterface::preSave() is fired on all field
+   * translations automatically.

This is one of the reasons why I was leaning towards having ::preSave() / ::postSave() (and hook_entity_update()) invoked on every translation object.

In the load/write workflow we act on every translation, as we did in D7, so I'd expect the system to do that consistently.

mkalkbrenner’s picture

This is one of the reasons why I was leaning towards having ::preSave() / ::postSave() (and hook_entity_update()) invoked on every translation object.

In the load/write workflow we act on every translation, as we did in D7, so I'd expect the system to do that consistently.

I would like to see that as well. But this means more code to fix in core. For example Node::postSave():

public function postSave(EntityStorageInterface $storage, $update = TRUE) {
    parent::postSave($storage, $update);

    // Update the node access table for this node, but only if it is the
    // default revision. There's no need to delete existing records if the node
    // is new.
    if ($this->isDefaultRevision()) {
      \Drupal::entityManager()->getAccessControlHandler('node')->writeGrants($this, $update);
    }

    // Reindex the node when it is updated. The node is automatically indexed
    // when it is added, simply by being added to the node table.
    if ($update) {
      node_reindex_node_search($this->id());
    }
  }

This code is not meant to be run for every translation. It needs to be wrapped like this:

public function postSave(EntityStorageInterface $storage, $update = TRUE) {
    parent::postSave($storage, $update);

    if ($this->isDefaultTranslation()) {

        ...

    }
  }

It would be really good to get some more opinions here.

mkalkbrenner’s picture

Issue summary: View changes
mkalkbrenner’s picture

Since #2566419: Using the "Autocomplete (Tags style)" widget for the author field does not save the submitted user has been committed, node's without a valid owner will be assigned to the anonymous user instead of the current user during save. Therefor I had to adjust the failing test to still demonstrate the architectural issue here.

mkalkbrenner’s picture

Title: preSave() and postSave() not working with ContentEntity translations » Document that preSave() and postSave() are not working with ContentEntity translations and fix Node::preSave()
Assigned: Unassigned » mkalkbrenner

Status: Needs review » Needs work

mkalkbrenner’s picture

Status: Needs work » Needs review
FileSize
2.14 KB
5.07 KB

And now the adjusted patch.

mkalkbrenner’s picture

Issue tags: +sprint

Status: Needs review » Needs work

The last submitted patch, 32: 2474075-32.patch, failed testing.

mkalkbrenner’s picture

hchonov’s picture

diff --git a/core/lib/Drupal/Core/Entity/EntityInterface.php b/core/lib/Drupal/Core/Entity/EntityInterface.php
...
+      // If no revision author has been set explicitly, make the node owner the
+      // revision author.
+      if (!$translation->getRevisionAuthor()) {
+        $translation->setRevisionAuthorId($translation->getOwnerId());
+      }

The revision_uid field in not translatable, therefore there is no need to check if the revision author is set on each translation.

When this is fixed I would RTBC it.

hchonov’s picture

The revision author field is not translatable, but with the current patch the behaviour is being changed.

There comes the question - the owner of which translation should be set as a revision author, in case the field is not set. Looking at NodeForm::submitForm in case of new revision the revision author is set as the current user. So if we want to keep EntityAPI and FormAPI synchronous we should set here the revision author as the current user as well.

mkalkbrenner’s picture

Status: Needs work » Needs review
FileSize
755 bytes
4.98 KB

@hchonov is right. It seems like #2566419: Using the "Autocomplete (Tags style)" widget for the author field does not save the submitted user accidentally changed the strategy for the revision author fallback in Node::preSave(). I set it back to the state it was before.
So it seems there's a lack of test coverage for the revision author. But that isn't directly related to this issue here.

Status: Needs review » Needs work

The last submitted patch, 38: 2474075-38.patch, failed testing.

The last submitted patch, 38: 2474075-38.patch, failed testing.

Status: Needs work » Needs review

mkalkbrenner queued 38: 2474075-38.patch for re-testing.

Status: Needs review » Needs work

The last submitted patch, 38: 2474075-38.patch, failed testing.

mkalkbrenner’s picture

OK, the only error message in the single failing test is

Fatal error: Call to a member function id() on a non-object in /var/lib/drupaltestbot/sites/default/files/checkout/core/modules/node/src/Tests/Migrate/d7/MigrateNodeTest.php on line 115
D

This test assumes that the node owner and the creator of a revision are identical. While this is correct for D7 where every translation is a single node, this is incompatible with D8!

In D8 every entity translation revision has its individual owner (like every node in D7). But the creator of a revision is a unique user ID, a non-translatable field shared across all translation. That's different from D7!
Migrate has to deal with that somehow.

The current patch just discovered that issue and it is not causing it.

mkalkbrenner’s picture

Title: Document that preSave() and postSave() are not working with ContentEntity translations and fix Node::preSave() » Fix Node::preSave() and document that preSave() and postSave() are not working with ContentEntity translations
Status: Needs work » Needs review
FileSize
576 bytes
4.77 KB

I removed the "fix" for the revision Author that is not directly related to this issue here. We should open a folloe-up for migrate.

mkalkbrenner’s picture

plach’s picture

Status: Needs review » Reviewed & tested by the community

I don't like to keep this asymmetry around, but it seems there's no consensus here about changing the current behavior, so at least this patch provides better documentation for it and fixes a bug in the process. We can certainly rehash this issue in a follow-up, if we end-up deciding we really wish to change this behavior.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/lib/Drupal/Core/Entity/EntityInterface.php
@@ -240,7 +240,13 @@ public function delete();
+   * over all translations if needed. This behavior is different from its
+   * counterpart in the Field API:
+   * \Drupal\Core\Field\FieldItemListInterface::preSave() is fired on all field
+   * translations automatically.

This could been This is different from its counterpart in the Field API, FieldItemListInterface::preSave(), which is fired on all field translations automatically.

And then we can add an @see \Drupal\Core\Field\FieldItemListInterface::preSave().

hchonov’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
4.88 KB
1.12 KB

Made the changes requested by @alexpott in the previous comment and I think giving back the RTBC status is justifiable in this case.

plach’s picture

RTBC +1

Wim Leers’s picture

Wow, this is an absolutely crucial bit of documentation! It's unfortunate that it behaves differently in Entity API and Field API.

Shouldn't we also document why this difference exists? If there's no technical reason, we could add a @todo to improve this behavior in D9? Otherwise it seems like an arbitrary difference that Drupal considers fine, which surely will annoy developers?

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

Setting back to needs work for #50

mkalkbrenner’s picture

Shouldn't we also document why this difference exists?

I don't see any reason why it's implemented like this. I only went this way to just document the difference, because @catch told me to that in IRC. Maybe he should express why.

If anyone else is of the opinion that both APIs should behave the same way, that will be a pretty easy change.
Some existing implementations of preSave() in core should not run multiple times. But it will be a very simple patch to add a check for the default translation to each of them to avoid multiple execution.

How can we decide what to do? I already asked that multiple times ;-)

Instead of adding a todo I would like to see that change before RC1.

plach’s picture

I think the only possible reason for things being as they are (aside from me not realizing this earlier), is what @Berdir mentioned in #12: consistency with entity hooks. Let's add that as "official" reason and get this in. I'd really be in favor of reconsidering this stuff in a follow-up, possibly taking also #2382675: hook_entity_create() affects the data of new translations of existing entities in unexpected and undocumented ways into account.

catch’s picture

I am not sure which is worse - firing once and having to handling translations, or firing for each translation and having to handle it firing multiple times.

I think this patch is better than the status quo. We should open another, major, follow-up to look into changing the behaviour - or possibly splitting the hook in two.

plach’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 55: 2474075-55.patch, failed testing.

catch’s picture

Status: Needs work » Reviewed & tested by the community
mkalkbrenner’s picture

Assigned: mkalkbrenner » Unassigned
alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 158d434 and pushed to 8.0.x. Thanks!

  • alexpott committed 158d434 on 8.0.x
    Issue #2474075 by mkalkbrenner, hchonov, plach, catch: Fix Node::preSave...
Gábor Hojtsy’s picture

Issue tags: -sprint

Thanks all for making this happen, implementers will remember your names :)

Status: Fixed » Closed (fixed)

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