This was split off of #2860097: Ensure that content translations can be moderated independently.
Problem/Motivation
After many discussions in #2860097: Ensure that content translations can be moderated independently and #2766957: Forward revisions + translation UI can result in forked draft revisions, we reached consensus that a pending revision is safe to use in a multilingual context only when it affects a single language. However creating a new revision that correctly takes this logic into account is not a trivial task.
Proposed resolution
Add a ContentEntityInterface::createNewRevision()
method returning a new entity object that is a merge of the active entity translation and the translations available in the default revision. Such an object can be saved and made the new default revision without affecting pending revisions in other languages.
Remaining tasks
- Fix blocking issue: #2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision
Validate the proposed solutionWrite a patchReviews
User interface changes
None
API changes
Added the RevisionableStorageInterface::createRevision()
method.
Data model changes
None
Comment | File | Size | Author |
---|---|---|---|
#70 | interdiff.txt | 2.3 KB | effulgentsia |
#64 | entity-new_revision-2924724-64.patch | 34.14 KB | plach |
Comments
Comment #2
plachThis should be more or less complete. The only outstanding issue is deciding how to deal with the
Queries
class.Comment #3
plachSpoke with @catch about this, we both think it should be fine to just move the
Queries
methods onto the storage class.Comment #4
plachThis implements #3.
Comment #6
Gábor HojtsyHm is this really a magic constant? (No actual constant for revision type values?)
Is the method name correct if the method always starts creating the revision from the default revision? I see naming it createNewDefaultRevision() would not be correct because the new revision to create is not necessarily default, but the source data used is from the default revision (not necessarily the latest revision). That may be important to reflect in the method name somehow though.
These are all BC changes. I see you added default implementations to the base class, which mitigates this a bit but it would fatal projects using this interface and not using the base class. We are not supposed to make changes like that.
I see if we introduce a new interface then we cannot mandate that is implemented by content entity storages in general. Can we still work with that in the implementation, maybe throw an exception if we don't have the right data due to missing methods? That would at least only make the new addition broken for people with old interfaces. That is backwards compatible.
(Better ideas welcome, just thinking out loud).
Comment #7
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedI've split the addition of a
getLatestRevisionId()
method to a new issue (#2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision) because I think it should be easier to discuss it in a smaller scope.Comment #8
plachThis should fix test failures.
@Gábor Hojtsy:
1: That's an unrelated hunk that was erroneously made part of the patch, it actually belongs in #2891215: Add a way to track whether a revision was default when originally created (to be heavily discussed ;) so I removed it, thanks!
2: Well, the default revision is the base for all translations except the active one, which is what you are supposed to be changing/saving. All values of the active translation are retained, even if you already performed some unsaved changes, so I think the method name should be correct. This method does not guarantee you do the right thing, i.e. changing only the active translation, but at least it guides you toward doing it.
3: According to our (not yet official) policy, those should be allowed changes:
Am I missing something?
Comment #9
plachSpoke with @hchonov about this, we have a few things to fix:
Additionally Hristo is not happy with the current method name, but we agreed to defer the bikeshedding to when everything else is fixed ;)
Comment #10
plachThis is blocking a critical task.
Comment #11
plachThis should address #9:
Comment #13
plachThe previous patch got a bit messed up, the interdiff was correct though.
Comment #14
plachThis now depends on #2926483: Add API methods for determining whether an entity object is the latest (translation-affecting) revision. Attaching a combined patch and patch to review. Leaving in needs review status to avoid blocking progress here.
Comment #16
plach#2878556: Ensure that changes to untranslatable fields affect only one translation in pending revisions is no longer critical.
Comment #17
plachThis should be better
Comment #18
plachFixed another problem that was not caught by the current test coverage.
Comment #19
plachComment #20
plachComment #21
plachComment #22
Gábor Hojtsy@plach re 7-8/3 you are right, these changes can be made according to our policy at least. :)
Comment #23
hchonovIf the name of the method is hasTranslations then we should check only if the current entity object has more than one entity translation and place it on the entity class.
If we want to check if the entity has translations in any pending revisions then such a method should be placed in the storage and the name should be something like hasTranslationsInPendingRevisions().
The entity might have translations even if it is new :). I mean this in case we place the method on the entity class and use it to check only the current state.
If we move the method to RevisionableStorageInterface then we don't have to check whether the entity type is revisionable :).
Let's just define the default value of the parameter $keep_untranslatable_fields to be FALSE and move the comment to the documentation of the parameter.
We could exchange this with something more easy to understand:
This is not needed anymore as now we start with a clone of the entity.
I am not sure if I understand why this is needed. Could you please elaborate a bit more on it?
What exactly does mean "to be sure this revision can be related to the correct translation"?
Actually the method will merge any other translations expect the current one of the default revision into the current entity no matter if the current entity is the latest affected revision or something else. So this piece of the comment is a little bit confusing.
Comment #24
plachThanks for the review! Here is a new patch and some replies:
1/2: The point of
::hasTranslations()
is checking whether there are stored translations, that's why it's on the storage class. In this case we are not interested in knowing whether the passed entity object has translations, we want to know whether there's any stored translation for that entity. The logic in the::createRevision()
method assumes to deal with one changed translation at a time, for pending revisions, so if an object with a non-stored translation is passed, and that is not the active translation, we are in a non-supported scenario. Unless the plan is to store the passed entity object first and then save the new revision, in which case I guess things should work, but I didn't try. Anyway, I was hoping that since the method is on the storage it would be clear that it refers to stored data, but maybe::hasStoredTranslations()
would be a better name?3:
RevisionableStorageInterface
should not know the concept of translations (separation of concerns), so far we have been putting methods supporting revisions + translations inContentEntityStorageInterface
. See also the related comment at #2926483-36: Add API methods for determining whether an entity object is the latest (translation-affecting) revision.4: Sorry, that's not obvious at all, but that code is a placeholder for the logic added in #2878556: Ensure that changes to untranslatable fields affect only one translation in pending revisions. I added a @todo to make it clearer.
5: Good point! Fixed.
6: Unfortunately it is still needed: if we are not keeping untranslatable fields, the revision ID is overwritten with the default revision value. We could add a check in the loop to always skip the revision ID, but I thought it was cleaner this way.
7: Sure thing: if we don't populate the
original
property, it will be populated by the storage, which would load the default revision. However if it loaded the correct revision, it might incorrectly mark all non-active translations as affected, because the default revision values might differ from the loaded revision ones. We can remove this line but it will have to be restored once we fix #2833084: $entity->original doesn't adequately address intentions when saving a revision and friends. Additionally I think it's useful to determine whether any value in the new revision object was changed, especially in non-active translations, that are not supposed to be changed. This would be mostly useful if we wanted to perform some kind of validation around this logic.8: I meant that since we are creating this new revision to perform changes on the active translation, it's important to store this information even if for some reason no actual change is performed.
9: You're right, that's a leftover of a previous approach I tried, fixed.
Comment #25
hchonov1./2. - The method is checking only forward revisions therefore the name should be specifying this. Not every developer is aware of forward revisions and
::hasStoredTranslations
will be confusing for such developers.3. Ok, I agree.
4. I still don't understand the point of this piece of code :). Why don't we set the default value of the parameter here where we introduce the method?
5. Thanks.
6. I personally would prefer if we skip synching the field at all. All the fields we don't have to sync are -
7. Ok, now I understand what you mean, but if you are going to save the entity as a default revision then original should be the currently default revision :). I would rather remove this piece of code here and fix this in the other issue which is dedicated to setting the right original entity.
8.
I am sorry, but I have to ask why is this important? :)
9. Thanks, I think that now the comment is more understandable :).
Comment #26
plachNew patch and more replies:
1/2: Addressed the issue as we discussed.
4: The logic in the followup cannot be assigned as a default, as it requires invoking a couple of methods, hence we need to know that the parameter was not explicitly passed (regardless of it being TRUE or FALSE) to be able to apply the default logic.
6: Done
7: Fair enough, I still think we should do something like that but I'm fine with discussing that in the issues fixing the
original
property.8: If we have a revision with no affected translation, how can we tell in which language it should appear on the revision overview page?
Comment #27
catchThis should have an accessCheck(FALSE). All the other queries in the patch look OK though.
Overall this is looking good to me.
Comment #28
plachFixed, thanks!
Comment #29
plachWrong interdiff
Comment #30
timmillwoodEither
$keep_untranslatable_fields
is wrong or the docblock is. Should the default be NULL or FALSE? Seems like it should just be bool, and not have a null value. In the implementation it is set to FALSE if NULL, however this is changed in #2878556: Ensure that changes to untranslatable fields affect only one translation in pending revisions and another implementation using this interface may do something different.Comment #31
plachDiscussed #30 with Tim and we decided to leave the default logic description for now, since it's going to change in the follow-up.
Comment #32
timmillwoodThanks @plach looks good to me now.
Ready to RTBC once the dependency is in.
Comment #33
plachI'd like to give @hchonov a chance to review the latest changes, but no hurry given that the blocker is still not ready.
Comment #34
plachComment #35
plachHere is a reroll now that blocker was committed.
Comment #36
plachComment #37
plachMinor PHP doc clean-up. Hopefully we move this back to RTBC now.
Comment #40
plachComment #42
plachTestbots--
Comment #43
gabesulliceCan you just
return TRUE;
? That would simplify the rest of this method.Rather than type juggling, can you
return !empty($query->execute());
. I think that makes the intent clearer."This permits the creation of pending revisions"
These can be combined.
s/allows to determine/determines/g
Nit: The entity type bundle info service.
Comment #44
plachThanks for the review! This should address #43 and a couple of issues I discovered while working on that.
1: Yep, the method body is short enough to make this a readability improvement.
2: I kept
$result
as it makes using the debugger easier.3: Fixed
4: I combined only the two last lines, otherwise we would be needlessly invoking
::getRevisionTranslationMergeSkippedFieldNames()
for each translation.5: Fixed
6: Fixed
I also realized this is not correct, only existing translations should trigger an early return. Ideally also removed translations, but those are not returned by
:: getTranslationLanguages()
.The contract for the translatable-only storage should not mention revisions. Those lines should be added in
TranslatableRevisionableStorageInterface
.Comment #45
gabesulliceAh, I'm with you now. I didn't like how "far" apart the variable assignment was from the usage, but now I see your reasoning.
Comment #46
amateescu CreditAttribution: amateescu for Pfizer, Inc. commentedHow about renaming this field to
non_mul_field
to match the existingnon_rev_field
?Comment #47
plachDone!
RTBC anyone? :)
Comment #48
effulgentsia CreditAttribution: effulgentsia at Acquia commentedWhat's the purpose of this method existing on this interface? Looks to me like the only utility of this method is for
TranslatableRevisionableStorageInterface
only.Are we sure we want this in the interface? If so, why? In the current patch, it looks like it could just as easily be a protected method of
ContentEntityStorageBase
. If there's future work that requires this to be in the interface, then I don't think the name makes it sufficiently clear that it queries across all revisions of $entity, not just$entity
's revision. What about something likehasTranslationsInAnyRevisions()
orisAnyRevisionTranslated()
? If we take it out of the interface and make it a protected method, then I'm less concerned about the name.Comment #49
plachWell, I thought that method might have been useful in the follow-up work dealing with translation removal, however I'm not 100% sure about that yet. We can certainly make it protected for now and "publish" it later, if needed. The only downside is we would lose test coverage that way. Unless we implement something like this test helper to deal with that: https://3v4l.org/rK9r7.
Comment #50
timmillwoodI quite like the idea of adding
invokeProtectedMethod()
in a trait (or the test class itself). It seems a nice way to keep the methods protected for now whilst testing. One of the biggest issues in D8 is too many public methods, so would be nice to make as much protected as possible, then change later if needed. The other option could be to@internal
everything.Comment #51
effulgentsia CreditAttribution: effulgentsia at Acquia commentedDon't we already have examples of tests that test protected methods by subclassing within the test and making it public in the subclass? If that's not possible for entity storage classes for some reason, I'd be fine with losing the test coverage of that method for now and adding it in a followup with whatever technique makes the most sense.
Comment #52
plachDone!
RTBC anyone? :)))
Comment #53
timmillwoodThe docblock says "has stored translations", but the method name has been changed to isAnyRevisionTranslated(), should the docblock be updated to similar wording?
Other than that nitpick, looks good to me.
Comment #54
plachFixed, thanks!
Comment #55
plachBetter fix
Comment #56
timmillwoodThanks @plach!
Comment #57
hchonovHm, this new storage class has no logic beside just passing the call to the parent. What is it then needed for?
Comment #58
hchonovOuh I got it now... It is because the parent method is protected and in the new storage class it is being made public, so that it could be tested ... Well as there is only one test testing that method, I think it would be better to use reflection there to access and execute the protected method instead of adding a new storage class.
Comment #59
plachI suggested exactly that in #49, but apparently that's not how we've been doing things so far (see #51). I guess switching to a reflection-based approach would need its own discussion, but obviously I'm +1 on the idea.
Comment #60
hchonovActually we have a lot of places where we use reflection to test non-public methods. Some of them are:
\Drupal\Tests\Core\DrupalKernel\DrupalKernelTest::testTrustedHosts()
is testing\Drupal\Core\DrupalKernel::setupTrustedHosts()
\Drupal\Tests\rest\Unit\EntityResourceValidationTraitTest
is testing\Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
\Drupal\Tests\Component\Plugin\Discovery\DiscoveryTraitTest::testDoGetDefinition()
is testing\Drupal\Component\Plugin\Discovery\DiscoveryTrait::doGetDefinition()
\Drupal\Tests\Core\Config\Entity\ConfigEntityBaseUnitTest::testAddDependency()
is testing\Drupal\Core\Config\Entity\ConfigEntityBase::addDependency()
I consider the entity_test module already overpopulated with stuff and I personally would add new classes only if they are really required.
Comment #61
plachI agree, however I'm not sure how to proceed here: I'd hate to lose test coverage, even temporarily, but adding that in a follow-up seems the only way forward to accommodate both #51 and #60.
Edit: unless we make the method public again, which is not probably a good strategy.
Comment #62
plachSpoke with @effulgentsia: he is fine with using reflection without an helper trait, so here's the updated patch.
Comment #63
effulgentsia CreditAttribution: effulgentsia at Acquia commentedLooks like #62 is failing tests on SQLite :(
Comment #64
plachBecause... why not? :)
Comment #65
plachSQLite fixed, back to Alex.
Comment #66
plachComment #67
hchonovI am really happy that we have a consensus here :).
@plach++
@effulgentsia++
Comment #68
effulgentsia CreditAttribution: effulgentsia at Acquia commentedTicking credit boxes for reviewers. Thank you for all the great reviews!
Comment #70
effulgentsia CreditAttribution: effulgentsia at Acquia commentedPushed to 8.5.x to unblock progress towards #2860097: Ensure that content translations can be moderated independently.
I think this needs a change record for the interface expansion, since it will require implementations not extending from
ContentEntityStorageBase
to implement the new method, so "Needs work" for that.I also applied this interdiff on commit to fix what phpcs was flagging as a coding standards violation.
Comment #71
plachThanks a lot!
CR at https://www.drupal.org/node/2936357
Comment #72
plach