Problem/Motivation

More unit testing goodness for #2052109: [meta] Expand phpunit tests for \Drupal\Component\Plugin classes.

This time we're testing Drupal\Component\Plugin\Derivative\DeriverBase:: getDerivativeDefinition(), which it turns out is buggy.

  public function getDerivativeDefinition($derivative_id, $base_plugin_definition) {
    if (!empty($this->derivatives) && !empty($this->derivatives[$derivative_id])) {
      return $this->derivatives[$derivative_id];
    }
    $this->getDerivativeDefinitions($base_plugin_definition);
    return $this->derivatives[$derivative_id];
  }

This method dutifully checks if the array elements exist on $derivatives, but then fails to check them again after calling getDerivativeDefinitions(). Furthermore, it's likely that the return value of getDerivativeDefinitions() should be stored in $derivatves, but the method doesn't do this.

Also, the behavior of DeriverInterface can't really be deduced from this base class. That is, don't look to this class for clues about how derivers should work, because it is inconsistent.

Proposed resolution

  • Analyze the need for storing the results of getDerivativeDefinitions().
  • Add a test which covers these cases.

Remaining tasks

User interface changes

API changes

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category This issue is a bug because DeriverBase is the base class for many other classes.
Unfrozen changes Potentially unfrozen because it adds automated regression tests.

Comments

mile23’s picture

Status: Active » Needs review
StatusFileSize
new2.4 KB
new2.43 KB
new643 bytes

These patches add a unit test for getDerivativeDefinition(). One passes, one fails, and the interdiff shows you how to make the passing test fail.

This demonstrates that if getDerivativeDefinition() is at the point where $derivatives does not contain the $derivative_id key, the method will always error out.

The last submitted patch, 1: 2380029_1_fail.patch, failed testing.

dawehner’s picture

+++ b/core/tests/Drupal/Tests/Component/Plugin/Derivative/DeriverBaseTest.php
@@ -0,0 +1,66 @@
+/**
+ * @coversDefaultClass Drupal\Component\Plugin\Derivative\DeriverBase
+ */
+class DeriverBaseTest extends UnitTestCase {

DOn't we still need a @group?

mile23’s picture

@dawehner: Yes, still needs coding standards stuff, but I'm just demoing the bug. I'm not sure what the fix is.

jhedstrom’s picture

Issue summary: View changes

Might one potential fix would be for getDerivativeDefinitions() to actually update $this->derivatives, and then ensure the $derivative_id exists?

xano’s picture

StatusFileSize
new3.12 KB

What about doing it like this? Less confusing logic, with a single point of failure (getDefinitions()).

xano’s picture

StatusFileSize
new3.05 KB

This actually makes more sense, test-wise.

jhedstrom’s picture

This fix makes sense to me. @dawehner, @Mile23?

mile23’s picture

Status: Needs review » Reviewed & tested by the community

I'm still unclear what Derivatives are. I just found what looked like a bug.

Based on @jhedstrom's endorsement, I'll say RTBC. Someone with more knowledge can shoot me down if needed. :-)

mile23’s picture

OK, Xano went out of his way to help me understand Derivatives, and this seems all reasonable and good, so RTBC++.

xano’s picture

Thanks for the reviews, and to @Mile23 for catching this bug in the first place!

alexpott’s picture

Still missing an @group.

Also this will result in a performance hit for things like SelectionBase::getDerivativeDefinition() - since that will now always call SelectionBase::getDerivativeDefinitions().

  /**
   * {@inheritdoc}
   */
  public function getDerivativeDefinitions($base_plugin_definition) {
    foreach ($this->entityManager->getDefinitions() as $entity_type_id => $entity_type) {
      $this->derivatives[$entity_type_id] = $base_plugin_definition;
      $this->derivatives[$entity_type_id]['entity_types'] = array($entity_type_id);
      $this->derivatives[$entity_type_id]['label'] = t('@entity_type selection', array('@entity_type' => $entity_type->getLabel()));
      $this->derivatives[$entity_type_id]['base_plugin_label'] = (string) $base_plugin_definition['label'];
    }
    return parent::getDerivativeDefinitions($base_plugin_definition);
  }
alexpott’s picture

Status: Reviewed & tested by the community » Needs work

Needs work for #12

xano’s picture

Theoretically that's a performance hit, but that's why most, if not all, plugin managers cache the processed plugin definitions. Even if a piece of code calls these deriver methods more than once, it's always been the deriver's responsibility to provide internal caching. Should we add that to SelectionBase?

xano’s picture

If you look closely, there's something else wrong here. DeriverBase::getDerivativeDefinition() statically caches derivatives, not per base plugin ID. This means that if you'd use the derivers as DeriverInterface permits by calling getDerivativeDefinition() for different base plugins, you'd keep getting the same results as the derivative definitions of the first base plugin are cached and returned on subsequent calls. The only reason we've never had this problem in core, is that DerivativeDiscoveryDecorator creates a new deriver every time it needs to use one.

xano’s picture

Status: Needs work » Needs review
StatusFileSize
new22.29 KB
new23.13 KB

This patch fixes the other bug as well, and makes sure that all overrides of DeriverBase::getDefinitions() use static caching.

Xano queued 16: drupal_2380029_16.patch for re-testing.

Xano queued 16: drupal_2380029_16.patch for re-testing.

Status: Needs review » Needs work

The last submitted patch, 16: drupal_2380029_16.patch, failed testing.

xano’s picture

Status: Needs work » Needs review
StatusFileSize
new23.15 KB

Re-roll.

Status: Needs review » Needs work

The last submitted patch, 20: drupal_2380029_20.patch, failed testing.

xano’s picture

Status: Needs work » Needs review
StatusFileSize
new516 bytes
new23.17 KB

Status: Needs review » Needs work

The last submitted patch, 22: drupal_2380029_22.patch, failed testing.

Status: Needs work » Needs review

Xano queued 22: drupal_2380029_22.patch for re-testing.

Xano queued 22: drupal_2380029_22.patch for re-testing.

Version: 8.0.x-dev » 8.1.x-dev

Drupal 8.0.6 was released on April 6 and is the final bugfix release for the Drupal 8.0.x series. Drupal 8.0.x will not receive any further development aside from security fixes. Drupal 8.1.0-rc1 is now available and sites should prepare to update to 8.1.0.

Bug reports should be targeted against the 8.1.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

mile23’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll, +Needs issue summary update

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

Drupal 8.1.9 was released on September 7 and is the final bugfix release for the Drupal 8.1.x series. Drupal 8.1.x will not receive any further development aside from security fixes. Drupal 8.2.0-rc1 is now available and sites should prepare to upgrade to 8.2.0.

Bug reports should be targeted against the 8.2.x-dev branch from now on, and new development or disruptive changes should 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.

yogeshmpawar’s picture

Status: Needs work » Needs review
StatusFileSize
new24.56 KB

I have rerolled the patch for 8.2.x version.

Status: Needs review » Needs work

The last submitted patch, 29: drupal-2380029-29.patch, failed testing.

manuel garcia’s picture

Issue tags: -Needs reroll
manuel garcia’s picture

Status: Needs work » Needs review

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

Drupal 8.2.6 was released on February 1, 2017 and is the final full bugfix release for the Drupal 8.2.x series. Drupal 8.2.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.3.0 on April 5, 2017. (Drupal 8.3.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.3.x-dev branch from now on, and new development or disruptive changes should 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.

yogeshmpawar’s picture

Any Update on this issue ?

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

Drupal 8.3.6 was released on August 2, 2017 and is the final full bugfix release for the Drupal 8.3.x series. Drupal 8.3.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.4.0 on October 4, 2017. (Drupal 8.4.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.4.x-dev branch from now on, and new development or disruptive changes should 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.

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

Drupal 8.4.4 was released on January 3, 2018 and is the final full bugfix release for the Drupal 8.4.x series. Drupal 8.4.x will not receive any further development aside from critical and security fixes. Sites should prepare to update to 8.5.0 on March 7, 2018. (Drupal 8.5.0-alpha1 is available for testing.)

Bug reports should be targeted against the 8.5.x-dev branch from now on, and new development or disruptive changes should be targeted against the 8.6.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

idebr’s picture

Status: Needs review » Needs work
Issue tags: +Needs reroll
jofitz’s picture

Version: 8.5.x-dev » 8.6.x-dev
Status: Needs work » Needs review
Issue tags: -Needs reroll
StatusFileSize
new20.86 KB

Re-roll (based the patch in #22 due to the test failures of the patch in #29).

Status: Needs review » Needs work

The last submitted patch, 38: 2380029-38.patch, failed testing. View results

jofitz’s picture

Status: Needs work » Needs review
StatusFileSize
new24.41 KB

Well that was an abject failure! Let's try that again...

Status: Needs review » Needs work

The last submitted patch, 40: 2380029-40.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

Version: 8.6.x-dev » 8.7.x-dev

Drupal 8.6.0-alpha1 will be released the week of July 16, 2018, which means new developments and disruptive changes should now be targeted against the 8.7.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.7.x-dev » 8.8.x-dev

Drupal 8.7.0-alpha1 will be released the week of March 11, 2019, which means new developments and disruptive changes should now be targeted against the 8.8.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.8.x-dev » 8.9.x-dev

Drupal 8.8.0-alpha1 will be released the week of October 14th, 2019, which means new developments and disruptive changes should now be targeted against the 8.9.x-dev branch. (Any changes to 8.9.x will also be committed to 9.0.x in preparation for Drupal 9’s release, but some changes like significant feature additions will be deferred to 9.1.x.). For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 8.9.x-dev » 9.1.x-dev

Drupal 8.9.0-beta1 was released on March 20, 2020. 8.9.x is the final, long-term support (LTS) minor release of Drupal 8, which means new developments and disruptive changes should now be targeted against the 9.1.x-dev branch. For more information see the Drupal 8 and 9 minor version schedule and the Allowed changes during the Drupal 8 and 9 release cycles.

Version: 9.1.x-dev » 9.2.x-dev

Drupal 9.1.0-alpha1 will be released the week of October 19, 2020, which means new developments and disruptive changes should now be targeted for the 9.2.x-dev branch. For more information see the Drupal 9 minor version schedule and the Allowed changes during the Drupal 9 release cycle.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

donquixote’s picture

I agree with the problem - the current DeriverBase is definitely flawed.

But I think one of our observations is wrong.

If you look closely, there's something else wrong here. DeriverBase::getDerivativeDefinition() statically caches derivatives, not per base plugin ID. This means that if you'd use the derivers as DeriverInterface permits by calling getDerivativeDefinition() for different base plugins, you'd keep getting the same results as the derivative definitions of the first base plugin are cached and returned on subsequent calls. The only reason we've never had this problem in core, is that DerivativeDiscoveryDecorator creates a new deriver every time it needs to use one.

I would say this is wrong with the current way that deriver classes are used.

Deriver classes get the base plugin id injected in the constructor or the ::create() method.
See ContainerDerivativeDiscoveryDecorator::getDeriver().

This means one deriver object is only ever used for the same base plugin definition.

But now the $base_plugin_definition is passed as a parameter to the ->getDerivativeDefinitions() method, which falsely suggests that the same deriver object could be used for different base plugin definitions.

Technically, even with the proposed patch, this is still not fully supported.
In theory, you can poison the cache if you have two different base definitions with the same id.
This should normally not happen, but this is relying a lot on the context in which this is called.

A cleaner solution would be to inject the base definition in the constructor and ::create(), alongside the base plugin id, and not pass it to the ->getDerivativeDefinitions() and ->getDerivativeDefinition() method.

Ofc changing this would cause BC issues.

-----

The sections below may become obsolete if we apply changes as proposed above.

---------

Originally, before writing the section above, I was going to say the proposed solution is BC-breaking.
There are subclasses in contrib that write directly to $this->derivatives.
Now we are changing the structure of the array, which breaks these subclasses.

In practice, this is not really as big a problem as I originally thought.
The new version of ->getDerivativeDefinition() no longer looks into $this->derivatives. So the subclass can basically do what it wants with this property.

It only becomes BC-breaking if we start using the deriver in a way where the poisoned cache actually matters.
I don't currently see that we plan to do that.

A problem is that we don't really push contrib modules to upgrade their subclasses, unless developers "read the manual", which most of them won't.
To be more recognizable, we could deprecate this base class and create a new one with different name.
(at the cost of wasting a nice class name)

BUT
All I wrote in this section kind of becomes obsolete with the earlier section.

-------

Now some other feedback.

    return isset($definitions[$derivative_id]) ? $definitions[$derivative_id] : NULL;

Can we use ?? null coalesce operator?

    return $definitions[$derivative_id] ?? NULL;
   public function getDerivativeDefinitions($base_plugin_definition) {
     return isset($this->derivatives[$base_plugin_definition['id']]) ? $this->derivatives[$base_plugin_definition['id']] : [];
   }

I don't think this provides a nice DX for the subclass.
The subclass still needs to write to the protected property, and then call the parent method. Not convenient.
What about this instead?

   public function getDerivativeDefinitions($base_plugin_definition) {
     return $this->derivatives[$base_plugin_definition['id']] ??= $this->doGetDerivativeDefinitions($base_plugin_definition);
   }
   abstract protected function doGetDerivativeDefinitions($base_plugin_definition): array;
donquixote’s picture

Actually, I wrote a new version of this class long ago, and shared it here :)
#2727011-34: [policy, no patch] Private vs protected, and the role of inheritance

I finally found a good example in the Drupal codebase!

Drupal\Component\Plugin\Derivative\DeriverBase.

An alternative implementation of this class would look like this:



abstract class DeriverBase implements DeriverInterface {

  /**
   * Base plugin definition.
   *
   * Subclasses don't need access to this.
   *
   * @var array|null
   */
  private $basePluginDefinition;

  /**
   * Derived plugin definitions.
   *
   * Subclasses don't need access to this.
   *
   * @var array[]|null
   */
  private $definitions;

  /**
   * {@inheritdoc}
   */
  final public function getDerivativeDefinition($derivative_id, $base_plugin_definition): ?array {
    $definitions = $this->getDerivativeDefinitions($base_plugin_definition);
    return $definitions[$derivative_id] ?? NULL;
  }

  /**
   * {@inheritdoc}
   */
  final public function getDerivativeDefinitions($base_plugin_definition): array {

    if ($this->definitions === NULL
      // In the unexpected case where a deriver is called with a different base
      // plugin definition than in a previous call, all derivatives need to be
      // reset.
      || $this->basePluginDefinition !== $base_plugin_definition
    ) {
      $this->definitions = $this->buildDerivativeDefinitions($base_plugin_definition);
      $this->basePluginDefinition = $base_plugin_definition;
    }

    return $this->definitions;
  }

  /**
   * Builds derivative definitions for a given base plugin definition.
   *
   * @param array $base_plugin_definition
   *   The definition array of the base plugin.
   *
   * @return array
   *   An array of full derivative definitions, keyed by derivative id.
   */
  abstract protected function buildDerivativeDefinitions(array $base_plugin_definition): array;

}

With a base class like this, subclasses don't ever need to touch the properties of the base class.

I have used this base class successfully in a project.

Version: 11.x-dev » main

Drupal core is now using the main branch as the primary development branch. New developments and disruptive changes should now be targeted to the main branch.

Read more in the announcement.