Comment does some voodoo to its entity_id base field to allow the target entity type to vary per bundle. This works really well and I've copied over that behavior in the Group module, but I recently ran into an issue with it which can also be reproduced in Comment.

If you create a Comment entity and for any reason call $comment->entity_id->entity to load the entity the comment belongs to, this returns NULL unless there is also a node (or user) with the same ID as the value of entity_id. This is because the bundle field definition simply clones the original base field and adds a target_type setting.

What happens is that the bundle field now has the propertyDefinitions property from the base field and the base field was set to target nodes (or users) because it didn't specify a target_type. See EntityReferenceItem::defaultStorageSettings() as to why that is.

So how does this break stuff?

Simple, try using $comment->entity_id->entity->label() on a newly created comment which is attached to any non-node content entity while the Node module is enabled and no nodes are created yet.

So how do we fix it?

Either allow us to unset the propertyDefinitions property (and perhaps schema as well) on a BaseFieldDefinition. This seems to be the goal of #2346329: hook_entity_base_field_info_alter() and hook_entity_bundle_field_info_alter() are documented to get a parameter that doesn't implement an interface with setter methods?

Or, do not simply clone the base field definition but recreate it. This is the temporary fix I've implemented in Group: #2734105: Adding a group for the first time crashes because of incorrect field definitions

The code that fixed it:

      /** @var \Drupal\Core\Field\BaseFieldDefinition $original */
      $original = $base_field_definitions['entity_id'];

      // Recreated the original entity_id field so that it does not contain any
      // data in its "propertyDefinitions" or "schema" properties because those
      // were set based on the base field which had no clue what bundle to serve
      // up until now. This is a bug in core because we can't simply unset those
      // two properties, see: https://www.drupal.org/node/2346329
      $fields['entity_id'] = BaseFieldDefinition::create('entity_reference')
        ->setLabel($original->getLabel())
        ->setDescription($original->getDescription())
        ->setConstraints($original->getConstraints())
        ->setDisplayOptions('view', $original->getDisplayOptions('view'))
        ->setDisplayOptions('form', $original->getDisplayOptions('form'))
        ->setDisplayConfigurable('view', $original->isDisplayConfigurable('view'))
        ->setDisplayConfigurable('form', $original->isDisplayConfigurable('form'))
        ->setRequired($original->isRequired());
CommentFileSizeAuthor
#35 interdiff-29-35.txt1.45 KBhchonov
#35 2734345-35.patch8.7 KBhchonov
#29 2734345-29.patch8.49 KBhchonov
#29 2734345-29-test-only.patch4.49 KBhchonov
#28 interdiff-27-28.txt5.92 KBhchonov
#28 2734345-28.patch8.49 KBhchonov
#28 2734345-28-test-only.patch5.14 KBhchonov
#27 2734345-27.patch4.51 KBjofitz
#27 interdiff-24-27.txt873 bytesjofitz
#24 comments_entity_id-2734345-24.patch4.86 KBGoZ
#20 comment-entity_id_base_field_not_overridden_correctly-2734345-20.patch4.56 KBdanmuzyka
#20 interdiff.txt865 bytesdanmuzyka
#17 interdiff.txt2.39 KBdanmuzyka
#17 comment-entity_id_base_field_not_overridden_correctly-2734345-17.patch4.31 KBdanmuzyka
#14 comment-entity_id_base_field_not_overridden_correctly-2734345-14.patch2.96 KBdanmuzyka
#14 interdiff.txt3.77 KBdanmuzyka
#7 comment-entity_id_base_field_not_overridden_correctly-2734345-7.patch4.59 KBdanmuzyka
#7 comment-entity_id_base_field_not_overridden_correctly-2734345-7-TEST_ONLY.patch2.36 KBdanmuzyka
#6 comment-entity_id_base_field_not_overridden_correctly-2734345-6.patch4.59 KBdanmuzyka
#5 comment-entity_id_base_field_not_overridden_correctly-2734345-5-TEST_ONLY.patch2.36 KBdanmuzyka
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

kristiaanvandeneynde created an issue. See original summary.

kristiaanvandeneynde’s picture

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.0-beta1 was released on August 3, 2016, which means new developments and disruptive changes should now be targeted against the 8.3.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.0-alpha1 will be released the week of January 30, 2017, which means new developments and disruptive changes should now be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

danmuzyka’s picture

Title: New comment entities cannot load whatever they're attached to » Comments: entity_id base field not overridden correctly in comment bundle field definitions for comment bundles attached to different entity target_type
FileSize
2.36 KB

I can confirm that \Drupal\comment\Entity\Comment::bundleFieldDefinitions() sets the target_type property of the entity_id field incorrectly. The problem is the way that the clone keyword behaves in PHP. As http://php.net/clone explains:

When an object is cloned, PHP 5 will perform a shallow copy of all of the object's properties. Any properties that are references to other variables will remain references.

$base_field_definitions['entity_id'] has a property named itemDefinition, which is an instance of the class \Drupal\Core\Field\TypedData\FieldItemDataDefinition. This object property is actually a reference to another object, so even though $base_field_definitions['entity_id'] is cloned into a new object, the itemDefinition property of the new object remains a reference to the same object as the original instance of $base_field_definitions['entity_id'] references. Therefore, the following line of code actually overrides the field definition of the entity_id field on all comment bundles that have already been built during the current HTTP request:

$fields['entity_id']->setSetting('target_type', $comment_type->getTargetEntityTypeId());

The result is that the bundle-specific overrides for all of the comment bundle field definitions that were already built during a given HTTP request are overridden by the settings for the next comment bundle's field definitions. However, before the process of building a comment bundle's field definitions overwrites the already-built field definitions of other comment bundles, those definitions have been saved to cache by \Drupal\Core\Entity\EntityFieldManager::getFieldDefinitions(). On the next HTTP request, after the cache has been warmed, EntityFieldManager::getFieldDefinitions() loads the correct field definitions for a given bundle out of cache. Therefore, this bug affects the copy of the field definitions in PHP memory during a single HTTP response after a cache clear, or when the discovery cache bin is disabled/set to null.

I'm attaching a KernelTest that demonstrates this bug. I'll post a stopgap patch with a workaround in a separate comment.

danmuzyka’s picture

Version: 8.4.x-dev » 8.2.x-dev
Status: Active » Needs review
FileSize
4.59 KB

Here is the patch.

danmuzyka’s picture

kristiaanvandeneynde’s picture

So currently the last bundle being built rules all previous bundles during the initial cache building, but because they are being cached individually they work well after being loaded from cache. Nice find!

The test looks great for this. Why do we need to install the 'sequences' schema, though?

I'm not 100% sure about the proposed fix. I mean, what stops other modules or places in core from having the same problem? Perhaps we should put some warning in the method documentation, explaining it is strongly discouraged to clone a base field definition.

You could move that huge comment to the doxygen block for FieldableEntityInterface::bundleFieldDefinitions() and refer to Comment::bundleFieldDefinitions() for an example.

Also, is there no other way but to replicate code from EntityFieldManager::buildBaseFieldDefinitions()? This is always a bad idea because it may become out of date as the original code is updated/fixed/etc. The approach I listed in the summary works well too, albeit a bit verbose.

Perhaps we need to introduce a magic __clone() method on BaseFieldDefinition to allow for cloning after all while making sure the copy is a deep copy?

TL;DR: Nice find, great test, rethink approach: Clone properly or document more?

kristiaanvandeneynde’s picture

Berdir’s picture

Hm, why don't we just implement __clone() on the BaseFieldDefinition object and possibly other related definition objects and make sure that we actually do a proper deep clone? Then it would just work?

kristiaanvandeneynde’s picture

Yes, exactly that!

danmuzyka’s picture

Assigned: Unassigned » danmuzyka

Thanks for the feedback @kristiaanvandeneynde and @Berdir ! Assigning to myself to work on an approach based on defining a __clone() method on the BaseFieldDefinition.

danmuzyka’s picture

I've attached a new patch (and an interdiff) that addresses the bug by implementing a deep clone using BaseFieldDefinition::__clone().

danmuzyka’s picture

Assigned: Unassigned » danmuzyka
Status: Needs review » Needs work

I am seeing on my local environment that the deep clone is not cloning all of the way down the chain of object properties...assigning back to myself to fix...

kristiaanvandeneynde’s picture

Yeah you'll need to implement __clone in every object that it might use as a property. And then some :)

danmuzyka’s picture

Here's a slightly different approach to the deep clone.

Given that BaseFieldDefinition->itemDefinition->fieldDefinition is a recursive reference back to the BaseFieldDefinition object, we need a way to overwrite that reference in the FieldItemDataDefinition class without having to instantiate a new instance of that class, and potentially losing any property values (e.g., custom values set by alter hooks). This patch adds a FieldItemDataDefinition::setFieldDefinition() public method to overwrite the existing value of BaseFieldDefinition->itemDefinition->fieldDefinition.

Berdir’s picture

Status: Needs review » Needs work
+++ b/core/lib/Drupal/Core/Field/TypedData/FieldItemDataDefinition.php
@@ -83,4 +83,17 @@ public function getFieldDefinition() {
+   * Sets the field item's field definition.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface
+   *
+   * @return static
+   *   The object itself for chaining.
+   */
+  public function setFieldDefinition($field_definition) {
+    $this->fieldDefinition = $field_definition;
+    return $this;

I see, this is tricky.

A bit weird to add this method without having it on an interface, but the get() above is the same (which btw is only used twice in search_api and nowhere else that I can find).

Can we add a note that this is only used internally by BaseFieldDefinition::__clone() and should not be used otherwise?

Also, misses description and variable name of the @param.

kristiaanvandeneynde’s picture

Patch looks good aside from Berdir's nitpick. I do agree that the circular reference is an annoying feature to have when trying to deep clone properly.

danmuzyka’s picture

Here's an update to the patch with the changes to the comment. Let me know if this looks OK or not.

kristiaanvandeneynde’s picture

Status: Needs review » Reviewed & tested by the community

Looks good to me!

I'd argue that it's not "The new field definition to assign this item definition." but rather "The new field definition to assign to this item definition.", but don't let such a small nitpick stand in the way of an RTBC stamp.

xjm’s picture

Version: 8.2.x-dev » 8.4.x-dev

Not sure why this issue was moved back to 8.2.x, but that branch has no further development since Feb. 1. Since this includes API additions and internal breaks/disruptions, it should be targeted for 8.4.x. Thanks!

alexpott’s picture

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

The patch on #20 does not apply to 8.4.x

+++ b/core/lib/Drupal/Core/Field/BaseFieldDefinition.php
@@ -723,4 +723,19 @@ public function getConfig($bundle) {
+  /**
+   * Magic method: Implements a deep clone.
+   */
+  public function __clone() {
+    // Ensure the itemDefinition property is actually cloned
+    // by overwriting the original reference.
+    $this->itemDefinition = clone $this->itemDefinition;
+
+    // The itemDefinition (\Drupal\Core\Field\TypedData\FieldItemDataDefinition)
+    // has a property fieldDefinition, which is a recursive reference to the
+    // parent BaseFieldDefinition. Need to overwrite the reference to the old
+    // object with a reference to the cloned one.
+    $this->itemDefinition->setFieldDefinition($this);
+  }

Is there any reason this can't be

  /**
   * Magic method: Implements a deep clone.
   */
  public function __clone() {
    // Ensure the itemDefinition property is actually cloned
    // by overwriting the original reference.
    $this->itemDefinition = FieldItemDataDefinition::create($this);;
  }

That way the setter is not needed.

GoZ’s picture

Status: Needs work » Needs review
FileSize
4.86 KB

Reroll patch applying #23 suggestion.

Status: Needs review » Needs work

The last submitted patch, 24: comments_entity_id-2734345-24.patch, failed testing.

jofitz’s picture

Issue tags: -Needs reroll

Needs work, but no longer needs a reroll.

jofitz’s picture

Status: Needs work » Needs review
FileSize
873 bytes
4.51 KB

In answer to @alexpott's question in #23, if FieldItemDataDefinition::create($this) is used then $this->itemDefinition->definition['settings'] is not set (it is with clone $this->itemDefinition) so I have re-rolled #20.

hchonov’s picture

Component: comment.module » field system
FileSize
5.14 KB
8.49 KB
5.92 KB

I've just came across this bug. The problem is not only in the comment module but in the field API itself, so I am setting the component to the field system. I've added one additional test, which is independent of the comment module.

+++ b/core/lib/Drupal/Core/Field/BaseFieldDefinition.php
@@ -755,4 +755,19 @@ public function setStorageRequired($required) {
+  public function __clone() {
...
+    $this->itemDefinition = clone $this->itemDefinition;

By cloning the item definition only in BaseFieldDefinition we are covering only the cloning of base field definitions objects, but we have to cover the cloning of the parent class as well. I've moved this piece of code to the parent class.

I've also created a new interface covering the two FieldItemDataDefinition methods - getFieldDefinition() and setFieldDefinition() and marked properly the setFieldDefinition() method as internal.

I am also uploading a test only patch.

hchonov’s picture

By mistake I've uploaded a not entirely clean test only patch and left some part of the fix inside, therefore I am uploading the test only and the full patch again.

The last submitted patch, 28: 2734345-28-test-only.patch, failed testing. View results

The last submitted patch, 29: 2734345-29-test-only.patch, failed testing. View results

kristiaanvandeneynde’s picture

Status: Needs review » Reviewed & tested by the community

By cloning the item definition only in BaseFieldDefinition we are covering only the cloning of base field definitions objects, but we have to cover the cloning of the parent class as well. I've moved this piece of code to the parent class.

Makes sense, back to RTBC. Perhaps a committer can fix the minor nitpick mentioned in #21?

larowlan’s picture

+++ b/core/modules/comment/tests/src/Kernel/CommentBundlesTest.php
@@ -0,0 +1,83 @@
+      // Verify that the target type remains correct
+      // in the deeply-nested object properties.

another nit: wraps too early here. (can be fixed on commit)

Berdir’s picture

+++ b/core/lib/Drupal/Core/Field/BaseFieldDefinition.php
@@ -838,4 +838,17 @@ public function setStorageRequired($required) {
+    $this->itemDefinition->setFieldDefinition($this);

Wondering if we should expliictly re-define the itemDefinition property in this class with the type it expectedly has here, or maybe even throw an exception if it doesn't match, to have a better error if someone did something weird?

This class also has a propertyDefinitions property. Not sure if we should also handle that here, but just unsetting that might make sense as it is basically just a static cache and the property definitions might also depend on the item definition.

Other than that this looks OK.

hchonov’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
8.7 KB
1.45 KB

Perhaps a committer can fix the minor nitpick mentioned in #21?

I've just updated it in the new patch.

Wondering if we should expliictly re-define the itemDefinition property in this class with the type it expectedly has here, or maybe even throw an exception if it doesn't match, to have a better error if someone did something weird?

What do you mean by "something weird"? :)

This class also has a propertyDefinitions property. Not sure if we should also handle that here, but just unsetting that might make sense as it is basically just a static cache and the property definitions might also depend on the item definition.

Although I don't think that this is dangerous I think we should take care of this as well. As we are defining proper cloning of the class BaseFieldDefinition I think it is justified to cover it in this issue, therefore I've included it in the new patch.

kristiaanvandeneynde’s picture

Status: Needs review » Reviewed & tested by the community

Looks good! Patch is functional so back to RTBC.

Re #34

Wondering if we should expliictly re-define the itemDefinition property in this class with the type it expectedly has here, or maybe even throw an exception if it doesn't match, to have a better error if someone did something weird?

Isn't that already taken care of by the setter/getter typehints? The only thing I can think of is that one would assign another BaseFieldDefinition to the property, even though the property is seemingly used to refer to a singular definition, not a list definition.

Not sure I can follow otherwise, the whole DataDefinition>ListDataDefinition>BaseFieldDefinition is a bit obscure to me. I.e.: I haven't really looked into the inner workings yet. If you're sure this patch needs the change you proposed, perhaps you can show us what you meant by it Berdir?

Berdir’s picture

What I meant is that someone could in theory have a subclass of BaseFieldDefinition that has an itemDefinition property that is *not* implementing the new interface. I think that's highly unlikely and protected properties are AFAIK also kind of excluded from BC, not sure.

PhpStorm currently also complains that setFieldDefinition() does not exist in DataDefinitionInterface when you look at the __clone() method.

That is why we coul do this:

  /**
   * The data definition of the base field
   *
   * @var \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface
   */
  protected $itemDefinition;

To at least document that we expect the item definition to be an instance of that. Or be even more strict and throw an exception if it is not, instead of letting it fatal.

I think the exception is probably overkill for a protected property but adding the property would be kind of useful?

kristiaanvandeneynde’s picture

Status: Reviewed & tested by the community » Needs work

Oh right, because we are calling setFieldDefinition() from BaseFieldDefinition and that method is only set on the new FieldItemDataDefinitionInterface interface. Yeah we should make clear that the BFD expects a FieldItemDataDefinitionInterface. When using BFD::create() it always will be, but you never know when someone might create one through the constructor.

Good catch!

hchonov’s picture

Hmm I am not really sure about this. I mean I like the idea with the typyhinting, but if we do this here then others might argue that we have to do it at each place where we extend from a class and expect different interfaces for properties or return values e.g. following this logic we have to redefine the method EntityStorageInterface::load() in the ContentEntityStorageInterface so that it returns a value of the type ContentEntityInterface.

kristiaanvandeneynde’s picture

Status: Needs work » Reviewed & tested by the community

Also a valid point. I'll put it back to RTBC seeing as BaseFieldDefinition is actually quite safe in a sense that its create() method makes sure we are dealing with a FieldItemDataDefinition. And given how we don't do the same typehinting elsewhere (see #39)...

Version: 8.4.x-dev » 8.5.x-dev

Drupal 8.4.0-alpha1 will be released the week of July 31, 2017, which means new developments and disruptive changes should now be targeted against the 8.5.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

  • catch committed 4f24c91 on 8.5.x
    Issue #2734345 by danmuzyka, hchonov, Jo Fitzgerald, GoZ,...

catch credited catch.

catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed 4f24c91 and pushed to 8.5.x. Thanks!

Not at all sure about an 8.4.x backport given the new interface etc., if this is blocking a contrib module we might have to make the new interface @internal or something. Moving straight to fixed but re-open if you really think this is needed.

kristiaanvandeneynde’s picture

@catch Group 8 currently uses the ugly workaround shown in the issue summary. Once this patch is "live", I can actually replace all of that code with a simple clone call. So while it doesn't necessarily need to be backported, it would help me remove some slow, ugly code from Group some three to six months earlier than 8.5.0

bojanz’s picture

This also affects Commerce, I'd love a core fix instead of having to copy Group's workaround.

kristiaanvandeneynde’s picture

@bojanz I believe it was fixed in core (8.5.x) by the commit in #42 :)

So the code in Comment (the entity) that used to break will now properly function:

$fields['entity_id'] = clone $base_field_definitions['entity_id'];
$fields['entity_id']->setSetting('target_type', $comment_type->getTargetEntityTypeId());
return $fields;

Which is why I'd love to see this in 8.4.x as well so Group and Commerce can already have the concise version when 8.4.0 lands.

Status: Fixed » Closed (fixed)

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