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
Comment | File | Size | Author |
---|---|---|---|
#55 | 2474075-55.patch | 5.03 KB | mkalkbrenner |
#28 | 2474075_node_preSave_sets_owner_on_translations_failing_test-27.patch | 2.28 KB | mkalkbrenner |
Comments
Comment #1
mkalkbrennerThis 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.
Comment #2
mkalkbrennerComment #3
mkalkbrennerComment #4
mkalkbrennerThis 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():
The test leads into a fatal error because the owner is not a valid object in all translations.
Comment #6
mkalkbrennerComment #7
mkalkbrennerNow see how NodeSaveTest::testPreSaveSetsOwner() acts including the patch from #1
Comment #8
mkalkbrennerComment #9
mkalkbrennerComment #10
mkalkbrennerComment #11
mkalkbrennerComment #12
BerdirNot convinced about this.
Why would we treat those methods different than the entity hooks?
Comment #13
mkalkbrenner@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:
I'll try to explain why.
The patch by @alexpott introduces a new property for entities:
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.
Comment #14
yched CreditAttribution: yched commentedThat 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 :-/
Comment #15
plachMy 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.
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).
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.Comment #16
plachRelated issues
Comment #17
plachComment #18
mkalkbrenner@plach: Just to clarify, even if I'm already convinced that you understood my motivation for the patch :-)
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?
Comment #19
mkalkbrennerAny suggestions how to proceed here?
Comment #20
mkalkbrennerI created a more readable failing test.
Comment #22
mkalkbrennerHere's a proposed solution to this problem as discussed with @catch on IRC:
I extended the documentation of
EntityInterface:preSave()
andEntityInterface:postSave()
and modified the implementation ofNode::preSave()
accordingly to iterate over all translations.Comment #23
mkalkbrennerComment #24
mkalkbrennerComment #25
plachThe patch looks ok, if this is the route we want to go, however:
This is one of the reasons why I was leaning towards having
::preSave()
/::postSave()
(andhook_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.
Comment #26
mkalkbrennerI would like to see that as well. But this means more code to fix in core. For example Node::postSave():
This code is not meant to be run for every translation. It needs to be wrapped like this:
It would be really good to get some more opinions here.
Comment #27
mkalkbrennerComment #28
mkalkbrennerSince #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.
Comment #29
mkalkbrennerComment #32
mkalkbrennerAnd now the adjusted patch.
Comment #33
mkalkbrennerComment #35
mkalkbrennerComment #36
hchonovThe 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.
Comment #37
hchonovThe 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.
Comment #38
mkalkbrenner@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.
Comment #43
mkalkbrennerOK, the only error message in the single failing test is
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.
Comment #44
mkalkbrennerI removed the "fix" for the revision Author that is not directly related to this issue here. We should open a folloe-up for migrate.
Comment #45
mkalkbrennerComment #46
plachI 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.
Comment #47
alexpottThis 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()
.Comment #48
hchonovMade the changes requested by @alexpott in the previous comment and I think giving back the RTBC status is justifiable in this case.
Comment #49
plachRTBC +1
Comment #50
Wim LeersWow, 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?Comment #51
alexpottSetting back to needs work for #50
Comment #52
mkalkbrennerI 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.
Comment #53
plachI 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.
Comment #54
catchI 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.
Comment #55
mkalkbrennerAs @catch suggested I created #2577609: Add per-entity-translation pre/post save methods (and hooks?) so that preSave() and postSave() implementations don't need to iterate all translations for 8.1.x and added the todo to the patch.
Comment #56
plachBack to RTBC
Comment #58
catchComment #59
mkalkbrennerComment #60
alexpottCommitted 158d434 and pushed to 8.0.x. Thanks!
Comment #62
Gábor HojtsyThanks all for making this happen, implementers will remember your names :)