Problem/Motivation
Create an entity and save it. Now call setNewRevision(TRUE) on the entity and before saving call setNewRevision(FALSE). The first call to setNewRevision removes the revision_id so trying to stop the creation of a new revision is not possible by calling setNewRevision a second time. Saving the entity now results in a new revision.
Proposed resolution
Make the function setNewRevision remember the changes it made and revert them if called again with TRUE.
Remaining tasks
review.
User interface changes
none
API changes
none
Data model changes
none
| Comment | File | Size | Author |
|---|---|---|---|
| #59 | 2544790-59.patch | 12.56 KB | hchonov |
| #59 | interdiff-56-59.txt | 3.97 KB | hchonov |
| #56 | 2544790-56.patch | 12.46 KB | hchonov |
| #56 | interdiff-53-56.txt | 5.57 KB | hchonov |
| #53 | 2544790-53.patch | 13.11 KB | hchonov |
Comments
Comment #1
hchonovand here the test and the fix together.
Comment #6
wim leersThis should be triaged by entity system maintainers.
Comment #7
hchonovThis could be solved later by #2620980: Add static and persistent caching to ContentEntityStorageBase::loadRevision() which will introduce a new function ::getOriginalRevisionId, so calling setNewRevision(FALSE) might reset the revision id, if it is not set.
Comment #8
berdirAgree that this is a problem, blocked on #2809227: Store revision id in originalRevisionId property to use after revision id is updated at the moment.
Comment #10
tstoecklerDiscussed this today with @Berdir, @amateescu, @plach and @xjm and agreed that this is a major issue.
Currently if you in the presave-phase of saving an entity decide to not create a new revision for the entity and, thus, call
setNewRevision(FALSE)if some code (such as a form) has deided before that a revision should be created. There is no workaround for this, either. A use-case of this is disabling creating a new revision of an entity when nothing actually changed, even if revisioning is enabled by default otherwise. This is currently not possible due to this bug.However, with #2620980: Add static and persistent caching to ContentEntityStorageBase::loadRevision() in, this should be fairly easy to fix now.
Also changing the title to be a bit more descriptive.
Comment #11
hchonov@tstoeckler, I think you've meant the other issue - #2809227: Store revision id in originalRevisionId property to use after revision id is updated
Comment #12
tstoecklerYes, you're right, thanks ;-)
Comment #13
wim leersThis blocks #1863258, which is a blocker and contrib blocker (see #1863258-9: Move entity preparation from form controller to entity class: "last changed" timestamp not updated and "create new revision" setting not respected when updating entities via Entity API). That in turn blocks #2838395: [PP-1] Entity API bug affecting REST: "last changed" timestamp not updated and "create new revision" setting not respected when PATCHing via REST.
Comment #14
tstoecklerActually this is not as easy as I originally thought because of the setting of
->setRevisionTranslationAffected(). I think we have a pre-existing bug related to that, but I'm still in the process of reading the entirety of #2453153: Node revisions cannot be reverted per translation and also playing around with this locally. But it's going to be a bit tricky either way, I think.Comment #15
tstoecklerOpened #2889530: revision_translation_affected is not re-calculated when re-saving a revision for #14. Not sure if this should be blocked on that. If we just fix ignore the
revision_translation_affectedflag for the scope of this patch, then doing$entity->setNewRevision(TRUE)->setNewRevision(FALSE)will lead to therevision_translation_affectedflag being (re-)calculated incorrectly. Calculating it correctly, though, requires #2889530: revision_translation_affected is not re-calculated when re-saving a revision. So what we could also do (which is what I think I prefer, but not sure) is simply store the current values of therevision_translation_affectedflag when someone calls->setNewRevision(TRUE)in a member variable this is solely used to re-instate those values 2889530when someone calls->setNewRevision(FALSE)later in the same request (and ignored/thrown away otherwise).Comment #16
wim leersWhat's next here?
Comment #17
wim leersThis blocks #1863258: Move entity preparation from form controller to entity class: "last changed" timestamp not updated and "create new revision" setting not respected when updating entities via Entity API which blocks #2838395: [PP-1] Entity API bug affecting REST: "last changed" timestamp not updated and "create new revision" setting not respected when PATCHing via REST.
Comment #19
amateescu commentedAn alternative to this could be to set the translation affected flag to NULL for a new revision in the storage itself, in which case the code to handle
$entity->setNewRevision(TRUE); $entity->setNewRevision(FALSE);becomes much simpler :)Comment #21
hchonovThis would not work, at least not as it is in the current patch, because removing the following line from
ContentEntityBase::setNewRevision()leads to the first if condition in
ContentEntityStorageBase:: populateAffectedRevisionTranslations()evaluating to FALSE instead to TRUE anymorein which case the storage will not anymore set the revision translation affected flag to TRUE for an entity that is being saved with a new revision and has translation changes compared to the original unchanged entity.
Comment #22
amateescu commented@hchonov, tbh this is the first time I've looked at that code and I was just assuming things "blindly", so I'm glad that at least we have test coverage for it :) Since you have some actual knowledge in that area, do you think the general idea of moving that code to the storage is possible somehow?
Comment #23
hchonov@amateescu, as the revision translation affected flag is handled by the entity storage I think that we could probably set back the original values of the flag based on $entity->original in case the entity is being saved without a new revision - this should cover the case $
entity->setNewRevision(TRUE); $entity->setNewRevision(FALSE);The other piece of code inCEB::setNewRevision()probably could be moved as well to the storage in a condition for new revision.However I think we have a problem if we save the entity without creating a new revision, as in this case we would have problems what will be shown on the revision page, because if we do not create a new revision we have to use as $original the entity revision based on which the current revision has been created instead the current one - the current one makes sense as $original only in case we are saving a new revision. The idea of the revision translation affected flag is to indicate changes between revisions. To overcome this issue the current approach in the storage will recompute the affected flag only if it has been set to NULL by e.g.
CEB::setNewRevision(), this however does not cover the following case :After those steps the revision page should only list the revision ID 2, but it will still be showing the revision ID 3 even if revision 3 doesn't contain any changes compared to revision 2. We could fix this but the requirement for this is #2833084: $entity->original doesn't adequately address intentions when saving a revision.
I suggest the following before we introduce a way of retrieving the parent revision:
I suggest the following after we introduce a way of retrieving the parent revision:
Comment #24
hchonovUploading a patch that fixes the problem that a new revision is created and also moves the logic for the revision translation affected flag from
CEB::setNewRevision()toContentEntityStorageBase::populateAffectedRevisionTranslations().The only uncovered case that remains is saving an entity without new revision where the revision translation affected flag of the unchanged translation is TRUE, but this could not be solved here and until we have a way of retrieving the parent revision.
Comment #25
hchonovComment #27
wim leersGo @hchonov & @amateescu for getting this going again! 🎉👌
Comment #28
amateescu commentedI don't think we need to provide any safety net for the case outlined in #23. In the example you gave, the third call not creating a new revision when a field value was actually changed is a bug in the code that decided to not save a new revision, so not really core's responsibility :)
If we agree on that, we just need to remove the @todo from the patch. Other than that, the code looks great and well documented!
Comment #29
hchonovI've removed the @todo, but I've left the note why we could not do it :).
In this patch I had to slightly change the logic to make the existing test coverage in
Drupal\KernelTests\Core\Entity\ContentEntityChangedTestpass.Comment #30
hchonovBecause of the new logic in
::setNewRevision()where we recover the revision ID we have to reset the new revision flag in post save in order to cover the use case (existing test) where an entity is saved inhook_entity_insert() - entity_test_entity_insert()and::setNewRevision(FALSE)is called on it.This fixes the failures in
Drupal\KernelTests\Core\Entity\EntityLoadedRevisionTestComment #31
hchonovI think I would need some support for the failing test
\Drupal\Tests\content_moderation\Functional\ModerationFormTest::testContentTranslationNodeForm().The reason why the test fails is that in
\Drupal\content_moderation\EntityTypeInfo::formAlter()the following call returns NULL :$translation = $this->moderationInfo->getAffectedRevisionTranslation($latest_revision);because the node entity does not have any affected translation in the latest revision.
Looking at the test code which should be responsible for creating the latest revision I am confused why the test initially expected a new revision with the revision translation affected flag set to TRUE:
What I see here is that the node entity is being translated to french and the value of
'moderation_state[0][state]'is set to'draft'. Afterwards the node edit page is retrieved for the french translation and the form is saved with the value of'moderation_state[0][state]'set to'draft'- this means the entity is saved with no difference in comparison to the previous revision. Without understanding the big picture underneath for me it looks like this is how it should behave.@amateescu, could you please help me find out if the current patch is breaking something or we just found a bug in the test or CM?
Comment #34
hchonovThis will hopefully fix all the failing tests but the
ModerationFormTest.Comment #35
amateescu commented@hchonov, it can very well be a bug in CM's test coverage. I'll take a look in a few hours when I get to my laptop :)
Comment #37
hchonov@amateescu, the problem was neither CM nor it's test coverage. The problem was in the new approach. When we move the whole logic to the storage we still have one problem - the flag could be set outside the storage as well and in the storage we have to find out if the flag has been enforced from outside and in such case skip recomputing it. For this we use the original entity and there is no problem of detecting a change to the flag, but an enforced value is not that easy to determine - e.g. the flag is already TRUE and before saving the entity with a new revision without translation changes the flag is enforced to TRUE. Here I make use of the storage, where the value of the flag is the corresponding integer value of TRUE when the entity is loaded. This allows for strict comparison if
::setRevisionTranslationAffected()is called only with boolean values outside the storage. To cover the use case where the same entity object is saved multiple times we have to call::setRevisionTranslationAffected()with the corresponding integer value of TRUE inside the storage so that on subsequent calls we'll be still able to perform strict comparison.I am not sure if this is an acceptable solution, but I couldn't think of anything else that is not breaking the existing test coverage.
Comment #38
amateescu commentedUgh, that's.. very ugly :/ I also tried various things locally with no success, so I think what we realized at this point is that doing all the work in the storage handler is not really possible because of the reasons you mentioned in #37 (being able to set the flag from both outside and inside the storage), so let's go back to the original proposal: keep the previous values in a protected variable and restore them whenever
setNewRevision(FALSE)is called after asetNewRevision(TRUE).Comment #39
hchonovI completely agree with you that it is very very ugly solution :).
If we store the original values in a protected variable on the entity object, then there is one question that arises - what happens if between
setNewRevision(TRUE)andsetNewRevision(FALSE)the revision translation affected flag is changed? Could we just like this revert the value? I am not really sure what the right answer is.I think we still should find a way to decouple the method
setNewRevision()from any knowledge about the revision translation affected flag.Probably we could add a second argument to the method
setRevisionTranslationAffected()- $enforced, which by default isTRUE, but the storage will call the method with the argument set toFALSE. This has as a consequence that method calls outside the storage will by default enforce a value. If the value is enforced then it will be kept in a protected entity object property. Then we either need a new methodisRevisionTranslationAffectedEnforced()or a second argument toisRevisionTranslationAffected()- $is_enforced, which by default is FALSE and returns the field value, but when called with TRUE from inside the storage returns whether the value has been enforced from outside. Sure, it has the drawback that the method might be overwritten in a class extending from ContentEntityBase in which case it will not work.I haven't completely thought this through, but what do you think about something like this @amateescu? I mean not necessary this suggestion, but based on it you might probably (hopefully) have a new better idea? :)
Comment #40
hchonov@amateescu, I have another proposal -
let's introduce a new field type for the revision translation affected flag, which extends from the
BooleanItem, but has one second property "enforced" without schema for that property.ContnentEntityBase::setRevisionTranslationAffected()will always set the enforced property on the field to TRUE and in the storage we'll set it to FALSE immediately after the call toContnentEntityBase::setRevisionTranslationAffected(). This will allow us to cleanly detect if the flag has been modified from within or from outside the entity storage.Why is this better than a protected variable? Because no matter what crazy stuff we do with the entity, both properties will be returned when we call
$entity->revision_translation_affected->getValue()and we don't have to take care of proper cloning of the protected variable, which will be needed as the variable should be shared among all entity translation objects, because other translation objects might be destroyed by calling$entity->clearTranslationCache(). I think this should also cover use cases likeentity_create($entity_type, $entity->toArray());. With this approach the knowledge wherefrom the value has been set will be always accompanying the value.Comment #41
amateescu commented@hchonov, last night I re-read #2453153: Node revisions cannot be reverted per translation entirely, like @tstoeckler did in #14/#15 and in that issue there was at some point a proposal to have a field field type with this kind of logic, but it was rejected.
I was also thinking of something along the lines of "enforced" values, but I didn't elaborate it as much as you did in #39 so I think that proposal has the best chances of being accepted by other entity maintainers :) In short, let's go with #39 and see how it looks like in a patch. :)
Comment #42
hchonovLet's see if the tests are happy with this slightly different approach :)
Comment #43
amateescu commentedI think we're trying to move away from the pattern where a getter is also a setter if you give it a parameter.
As a general observation on the code (without stepping through each line in an IDE).. it looks like we're doing a bit too much, are you sure you're not trying to also fix #2889530: revision_translation_affected is not re-calculated when re-saving a revision in this patch?
Comment #44
hchonov@amateescu, you are right, by the way I've changed the storage I was actually solving as well #2889530: revision_translation_affected is not re-calculated when re-saving a revision. I've reduced the changes to the minimum required for the current issue - at least I hope so, the tests have the final word :).
I've introduce a setter for the enforcement.
Comment #45
amateescu commentedSuper nice! The latest patch looks so much better and easier to understand :)
How about renaming this to
enforceRevisionTranslationAffectedto keep it similar with the existingenforceIsNewvariable from the\Drupal\Core\Entity\Entityclass?I would remove the 'on the field' part at the end of the sentence, it doesn't say anything useful. Also, how about 'we have to restore' instead of 'we have to recover'?
I would prefer your original proposal to have an
$enforced = TRUEparameter forsetRevisionTranslationAffected()instead. What do you think? It is a flag that should only be used by the storage, so outside calls won't care too much about the second argument..IMO, it is not such an important part of the public API to warrant its own method that will clobber an auto-completed list of methods in an IDE for example.
Comment #46
hchonov1.& 2. I agree.
3. I like my original proposal better as well, but changing a method is BC break in case somebody already overrides the method in an entity class extending from
ContentEntityBase. To prevent a BC break I think it would be better to introduce the new setter method and deprecate it. Then in D9 replace the method by adding the new argument$enforced = TRUEtosetRevisionTranslationAffected(). Personally I cannot think of a reason for overriding the method in custom or contrib, but this is only my personal opinion. Should we first ask a committer if we're allowed to change the method or should we directly do it and then see what a committer thinks about this?Comment #47
amateescu commentedAdding a new parameter with a default value to a method is not a BC break as far as I know, but sure, let's ask a release manager first :)
Comment #48
hchonovAccording to https://www.drupal.org/core/d8-allowed-changes#definitions adding a new parameter to a method is a BC break:
Adding a parameter with a default value doesn't help us in case the method is overwritten, as then we cannot call the method with an argument different than the default one - we can, but it will not make a difference, as the overwritten method will not pass the argument to the parent method when calling it.
According to https://www.drupal.org/core/d8-bc-policy adding a new method to ContentEntityInterface is not a BC break:
Comment #49
amateescu commentedDamn.. ok, new method it is then :/
NW for the other two points.
Comment #50
hchonov@amateescu, personally I prefer that we just add a new parameter to
::setRevisionTranslationAffected(). I just wanted that it is clear that such a change is a BC break. As we both prefer adding a new default parameter I am uploading two patches in two subsequent comments - one modifying the::setRevisionTranslationAffected()and one introducing a new method instead. I would say, that we let a committer decide if a BC break at that place is worthy and allowed.In this comment is the patch modifying the method
::setRevisionTranslationAffected()instead of introducing a new method for enforcing the revision translation affected flag value.Comment #51
hchonovIn this comment is the patch introducing a new method for enforcing the revision translation affected flag value instead of modifying the method
::setRevisionTranslationAffected().Comment #52
amateescu commentedSorry to point this out after all the discussions that we went through in this issue, but this a big problem. We can not have different logic apply when we set the value of a field through its "dedicated" setter vs. the "regular" entity API setter.
I'm not yet sure what's the best way to proceed here :(
Comment #53
hchonovReally nice, that you've seen this and the change in content moderation was necessary :).
This means that we need the setter method for the enforcement instead of an additional parameter on the
::setRevisionTranslationAffected()method.Now we have two ways of solving this -
1. Listen for changes on the revision translation affected field in
ContentEntityBase::onChange(). This will not work if the field value is set with$notify = FALSE;, however I am not really sure that we should take care of the case where the field value is being changed without notifying the parent.2. Introduce a special field type for the revision translation affected field.
2. will cover more use cases, but 1. is the lightweight solution providing enough coverage, therefore the new patch is implementing 1..
Comment #54
hchonovComment #55
amateescu commentedRe #53: Awesome! I imagined some kind of
onChange()usage but I didn't think it would be this easy :) The patch looks almost ready, just a few more things to fix:Let's remove the extra space :)
The method name has changed in the meantime but I would remove the "by calling ...()" part, it doesn't really matter how we enforce it.
I think @internal needs to go after @return.
Let's revert this change to make sure that #53 actually fixed the problem :)
This doesn't need to live in a browser test, we're only using API calls here. I would suggest to move this new test method to
EntityRevisionTranslationTest.This can be just
@covers setNewRevision.I think our coding standards say to open the array on the line above and close it on the last line.
Let's use
assertEquals()and put$entity_rev_idas the first param.Comment #56
hchonov1. Done.
2. Done & changed the comment a bit. What do you think about the new version?
3. I think you are right, but I wasn't sure myself as looking at couple of places in core it is the other way around. Still I've changed it now :).
4. Done. Should we really open a new issue just for that change? :)
5. Done.
6. Done.
7. Done.
8. Done.
Thank you for the extended review, @amateescu!
Comment #57
amateescu commentedRe #56.2: That's much better.
Re #56.4: Nope, I just wanted to have both kind of calls in core (dedicated and regular setter), so reverting that change to CM is enough for me :)
I think this is finally ready!
Comment #58
catchQuite minor issues with the comments, but this is a little bit hard to follow and I had to read some of the comments several times.
I started typing up nitpicks with method names, but just could not think of anything to replace them with.
'ensure' here sounds like we're checking if it's enforced or not. Something like 'When setting the revision translation affected flag we have to explicitly set it to not be enforced'?
We use revisionable everywhere else in core so should probably be consistent.
The second part of this comment is a bit confusing - why won't it have any effect? How do we end up in that situation?
Comment #59
hchonov@catch:
1. Done.
2. Done.
3. The value of NULL of the flag is being used to force the storage to recompute it. From the documentation of
ContentEntityInterface::setRevisionTranslationAffected():I've changed the comment of
ContentEntityInterface::setRevisionTranslationAffectedEnforced()a bit and added @see toContentEntityInterface::setRevisionTranslationAffected().Comment #60
amateescu commentedYay for doc improvements :)
Comment #61
catchCommitted/pushed to 8.5.x, thanks!
Comment #62
wim leersThis commit was not yet actually pushed. 🤐 😛
Comment #64
catchNow it is!
Comment #65
wim leersWorked on follow-ups: